chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
71 changed files with 9068 additions and 157 deletions
Showing only changes of commit a79f0791d0 - Show all commits
+29 -1
View File
@@ -1,4 +1,32 @@
import { isSendMessageRequest } from './app.setup';
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
for (const [method, url] of [
['POST', '/api/drafts'],
['POST', '/API/Drafts/'],
['PUT', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/?x=1'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(true);
}
});
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
for (const [method, url] of [
['GET', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts'],
['POST', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/attachments'],
['PATCH', '/api/drafts/faaaaabi'],
['POST', '/api/draftsx'],
['PUT', '/drafts/faaaaabi'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(false);
}
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
});
});
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
+30 -10
View File
@@ -8,37 +8,57 @@ import type { IncomingMessage } from 'http';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
export const API_PREFIX = 'api';
/**
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)을 뺀 모든 API.
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API.
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
*/
export const JSON_BODY_LIMIT = '1mb';
/**
* 발송 요청(`POST /api/messages`)의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
* 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
* 앞단 nginx 도 이 경로만 4m 으로 둔다 (release-ops). 넘으면 413 FILE_002.
* 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다.
* 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002.
*/
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
}
/**
* 발송 요청인가 — `POST /api/messages`.
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다: Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다.
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
*/
export function isSendMessageRequest(
req: Pick<IncomingMessage, 'method' | 'url'>,
): boolean {
if (req.method !== 'POST') return false;
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
return path === SEND_MESSAGE_PATH;
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
}
/**
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
*/
export function isDraftSaveRequest(
req: Pick<IncomingMessage, 'method' | 'url'>,
): boolean {
const path = routedPath(req);
if (req.method === 'POST') return path === DRAFTS_PATH;
if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) {
return false;
}
const id = path.slice(DRAFTS_PATH.length + 1);
return id !== '' && !id.includes('/');
}
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
@@ -81,14 +101,14 @@ export function configureApp(app: NestExpressApplication): void {
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
//
// 경로별 한도: 발송 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
app.useBodyParser('json', {
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
isSendMessageRequest(req) && isJsonBody(req),
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
});
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
+7
View File
@@ -119,6 +119,13 @@ const DOMAIN_ERROR_CODES = {
message:
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
},
// 임시 저장본 교체(PUT /api/drafts/{id}) 때 옛 초안이 이미 없다 — 다른 창·기기에서 먼저 고쳤거나 지웠다. 아무것도 저장하지 않는다.
// 클라이언트는 막지 않고 지금 내용을 새 임시 저장본(POST)으로 남긴다 (계약 2절 "임시 저장")
MAIL_008: {
status: HttpStatus.CONFLICT,
message:
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -21,7 +21,11 @@ interface CapturedResponse {
}
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
function runFilter(
exception: unknown,
requestId?: string,
url = '/api/users/1',
): CapturedResponse {
const captured = {} as CapturedResponse;
const response = {
@@ -37,7 +41,7 @@ function runFilter(exception: unknown, requestId?: string): CapturedResponse {
const request = {
method: 'GET',
url: '/api/users/1',
url,
header: () => requestId,
};
@@ -152,6 +156,22 @@ describe('HttpExceptionFilter', () => {
);
});
it('로그의 요청 주소에서 검색어(q)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
const warnLog = jest.spyOn(Logger.prototype, 'warn');
warnLog.mockClear();
runFilter(
new BusinessException('VAL_001'),
'trace-q',
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&cursor=bad',
);
const logged = warnLog.mock.calls.map(([message]) => String(message));
expect(logged).toEqual([
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&cursor=bad',
]);
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
@@ -17,6 +17,7 @@ import {
resolveCodeByStatus,
} from '@/common/errors/error-codes';
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
import { maskUrlForLog } from '@/shared/utils/mask';
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
const SERVER_ERROR_STATUS_THRESHOLD = 500;
@@ -140,7 +141,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
request: Request,
traceId?: string,
): void {
const where = `${request.method} ${request.url}`;
// 검색어(q)는 길이만 — 사람 이름·주소가 든다 (shared/utils/mask.ts)
const where = `${request.method} ${maskUrlForLog(request.url)}`;
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
const context =
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 서명·서명 그림·사내 목록 표 (docs/mail/api-contract.md 2절 "서명" · "받는 사람 추천").
* - mail_signatures: 사용자마다 서명 하나 (정화한 HTML·텍스트·넣을 메일)
* - mail_signature_images: 서명 그림 정보 — 파일은 업로드 볼륨의 mail-signatures 폴더. 주인 조회용 인덱스(user_email),
* 정리 작업용 부분 인덱스(attached_at 이 비어 있는 행의 created_at)
* - mail_directory: 웹메일 로그인에 성공한 직원 (받는 사람 추천의 "사내")
* 새 표만 만든다 — 기존 표(auth_sessions)는 건드리지 않는다.
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-signature/*.entity.ts · modules/mail-directory/mail-directory.entity.ts)
*/
export class AddMailSignaturesAndDirectory1793084109734 implements MigrationInterface {
name = 'AddMailSignaturesAndDirectory1793084109734';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "mail_directory" ("email" character varying(254) NOT NULL, "name" character varying(200) NOT NULL, "last_login_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_d79c6055eed1d875a53a13a71f8" PRIMARY KEY ("email"))`,
);
await queryRunner.query(
`CREATE TABLE "mail_signature_images" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "content_type" character varying(32) NOT NULL, "size" integer NOT NULL, "width" integer NOT NULL, "height" integer NOT NULL, "file_name" character varying(64) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "attached_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_f52f2c8dbb00f2973088763a776" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signature_images_unattached" ON "mail_signature_images" ("created_at") WHERE "attached_at" IS NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signature_images_user_email" ON "mail_signature_images" ("user_email") `,
);
await queryRunner.query(
`CREATE TABLE "mail_signatures" ("user_email" character varying(254) NOT NULL, "body_html" text, "body_text" text, "is_for_new" boolean NOT NULL DEFAULT true, "is_for_reply" boolean NOT NULL DEFAULT true, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "mail_signatures"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signature_images_user_email"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signature_images_unattached"`,
);
await queryRunner.query(`DROP TABLE "mail_signature_images"`);
await queryRunner.query(`DROP TABLE "mail_directory"`);
}
}
+3
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailModule } from '@/modules/mail/mail.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionEntity } from './auth-session.entity';
import { AuthSessionRepository } from './auth-session.repository';
@@ -29,6 +30,8 @@ import { LoginAttemptService } from './login-attempt.service';
JwtModule.register({}),
TypeOrmModule.forFeature([AuthSessionEntity]),
MailModule,
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
MailDirectoryModule,
],
controllers: [AuthController],
providers: [
@@ -4,6 +4,7 @@ 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 { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import type { AuthSessionEntity } from './auth-session.entity';
import { hashRefreshToken } from './auth-session.policy';
import type {
@@ -135,6 +136,7 @@ describe('AuthService', () => {
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
let attempts: LoginAttemptService;
let sessions: FakeSessions;
let directory: { recordLogin: jest.Mock };
let jwt: JwtService;
let service: AuthService;
let logs: string[];
@@ -147,6 +149,7 @@ describe('AuthService', () => {
jwt,
attempts,
sessions as unknown as AuthSessionRepository,
directory as unknown as MailDirectoryService,
);
const decode = (token: string) =>
@@ -184,6 +187,7 @@ describe('AuthService', () => {
};
attempts = new LoginAttemptService();
sessions = new FakeSessions();
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
jwt = new JwtService({});
service = build();
@@ -235,6 +239,31 @@ describe('AuthService', () => {
);
});
it('로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천) — 실패한 로그인은 기록하지 않는다', async () => {
await service.login(ARA, PASSWORD, CLIENT_IP);
expect(directory.recordLogin).toHaveBeenCalledWith({
email: ARA,
name: '김아라',
});
directory.recordLogin.mockClear();
mail.verifyCredentials.mockResolvedValue(null);
expect(
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
).toBe('AUTH_004');
expect(directory.recordLogin).not.toHaveBeenCalled();
});
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
directory.recordLogin.mockRejectedValue(new Error('db down'));
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
expect(result.user.email).toBe(ARA);
expect(logs.some((line) => line.includes('사내 목록'))).toBe(true);
expect(logs.join('\n')).not.toContain(ARA);
});
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
mail.verifyCredentials.mockResolvedValue(null);
+21
View File
@@ -7,6 +7,7 @@ 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 { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { SessionTokens } from './auth-cookies';
@@ -73,6 +74,7 @@ export class AuthService {
private readonly jwt: JwtService,
private readonly attempts: LoginAttemptService,
private readonly sessions: AuthSessionRepository,
private readonly directory: MailDirectoryService,
) {}
/**
@@ -115,10 +117,29 @@ export class AuthService {
}
const tokens = await this.startSession(profile);
await this.recordInDirectory(profile);
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
return { user: toUser(profile), tokens };
}
/**
* 로그인한 직원을 사내 목록에 기록한다 (받는 사람 추천 — 계약 2절). 관리자·게이트웨이·막은 계정은 넣지 않는다.
* 기록하지 못해도 로그인은 그대로 성공한다 — 추천에서 빠질 뿐이다.
*/
private async recordInDirectory(profile: MailAccountProfile): Promise<void> {
if (!this.isLoginAddress(profile.email)) return;
try {
await this.directory.recordLogin({
email: profile.email,
name: profile.name,
});
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 사내 목록에 로그인한 직원을 기록하지 못했습니다: user=${maskEmail(profile.email)}, reason=${errorMessage(error)}`,
);
}
}
/**
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
*
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** 표시 이름 최대 길이 — mail.constants 의 MAX_DISPLAY_NAME_LENGTH 와 같다 */
export const DIRECTORY_NAME_LENGTH = 200;
/**
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
*/
@Entity({ name: 'mail_directory' })
export class MailDirectoryEntryEntity {
/** 정규화한(소문자) 직원 주소 */
@PrimaryColumn({ type: 'varchar', length: EMAIL_COLUMN_LENGTH })
email!: string;
/** 마지막으로 로그인했을 때 메일 엔진이 알려 준 표시 이름 */
@Column({ type: 'varchar', length: DIRECTORY_NAME_LENGTH })
name!: string;
@Column({ name: 'last_login_at', type: 'timestamptz' })
lastLoginAt!: Date;
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailDirectoryEntryEntity } from './mail-directory.entity';
import { MailDirectoryService } from './mail-directory.service';
/**
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
*/
@Module({
imports: [TypeOrmModule.forFeature([MailDirectoryEntryEntity])],
providers: [MailDirectoryService],
exports: [MailDirectoryService],
})
export class MailDirectoryModule {}
@@ -0,0 +1,62 @@
import { MoreThanOrEqual, type Repository } from 'typeorm';
import type { MailDirectoryEntryEntity } from './mail-directory.entity';
import {
MAIL_DIRECTORY_ACTIVE_DAYS,
MAX_DIRECTORY_CONTACTS,
MailDirectoryService,
} from './mail-directory.service';
describe('MailDirectoryService (사내 목록)', () => {
let upsert: jest.Mock;
let find: jest.Mock;
let service: MailDirectoryService;
beforeEach(() => {
upsert = jest.fn().mockResolvedValue(undefined);
find = jest.fn().mockResolvedValue([]);
service = new MailDirectoryService({
upsert,
find,
} as unknown as Repository<MailDirectoryEntryEntity>);
});
it('로그인한 직원을 주소 하나에 한 줄로 넣거나 고친다 — 주소는 소문자, 이름은 앞뒤 공백 없이 200자까지', async () => {
const now = new Date('2026-10-27T03:00:00Z');
await service.recordLogin(
{ email: ' Doyun.Lee@ARASYSTEM.test ', name: ` ${'가'.repeat(250)} ` },
now,
);
expect(upsert).toHaveBeenCalledWith(
{
email: 'doyun.lee@arasystem.test',
name: '가'.repeat(200),
lastLoginAt: now,
},
['email'],
);
});
it(`최근 ${MAIL_DIRECTORY_ACTIVE_DAYS}일 안에 로그인한 직원만, 최근에 로그인한 사람부터 상한까지 주소·이름만 읽는다 (퇴사자는 추천하지 않는다)`, async () => {
const now = new Date('2026-10-27T03:00:00Z');
find.mockResolvedValue([
{
email: 'doyun.lee@arasystem.test',
name: '이도윤',
lastLoginAt: new Date(),
},
]);
await expect(
service.listContacts(MAX_DIRECTORY_CONTACTS, now),
).resolves.toEqual([{ email: 'doyun.lee@arasystem.test', name: '이도윤' }]);
expect(find).toHaveBeenCalledWith({
// 2026-10-27 의 90일 전 = 2026-07-29
where: { lastLoginAt: MoreThanOrEqual(new Date('2026-07-29T03:00:00Z')) },
order: { lastLoginAt: 'DESC' },
take: MAX_DIRECTORY_CONTACTS,
});
});
});
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, type Repository } from 'typeorm';
import type { DirectoryContact } from '@/modules/mail/mail.types';
import {
DIRECTORY_NAME_LENGTH,
MailDirectoryEntryEntity,
} from './mail-directory.entity';
/** 추천에 읽어 오는 사내 목록 상한 — 최근에 로그인한 사람부터 (추천 목록 자체는 1,000개까지다) */
export const MAX_DIRECTORY_CONTACTS = 2000;
/**
* 이 기간 안에 로그인한 직원만 "사내"로 추천한다 — 퇴사자가 계속 사내 맨 앞에 나와 반송되지 않게.
* 웹메일을 쓰는 직원은 세션이 길어야 30일이라 그 안에 다시 로그인한다. 표에서 지우지는 않는다 (다시 로그인하면 돌아온다).
*/
export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
*/
@Injectable()
export class MailDirectoryService {
constructor(
@InjectRepository(MailDirectoryEntryEntity)
private readonly entries: Repository<MailDirectoryEntryEntity>,
) {}
/** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */
async recordLogin(
contact: DirectoryContact,
now: Date = new Date(),
): Promise<void> {
await this.entries.upsert(
{
email: contact.email.trim().toLowerCase(),
name: contact.name.trim().slice(0, DIRECTORY_NAME_LENGTH),
lastLoginAt: now,
},
['email'],
);
}
/** 최근 MAIL_DIRECTORY_ACTIVE_DAYS 일 안에 로그인한 직원 — 최근에 로그인한 사람부터 */
async listContacts(
limit: number = MAX_DIRECTORY_CONTACTS,
now: Date = new Date(),
): Promise<DirectoryContact[]> {
const activeSince = new Date(
now.getTime() - MAIL_DIRECTORY_ACTIVE_DAYS * DAY_MS,
);
const rows = await this.entries.find({
where: { lastLoginAt: MoreThanOrEqual(activeSince) },
order: { lastLoginAt: 'DESC' },
take: limit,
});
return rows.map(({ email, name }) => ({ email, name }));
}
}
@@ -0,0 +1,122 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/modules/mail-signature/mail-signature.constants';
import type {
MailSignature,
SignatureImage,
} from '@/modules/mail-signature/mail-signature.types';
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
const IMAGE_TYPES: SignatureImageType[] = [
'image/png',
'image/jpeg',
'image/gif',
];
/** PUT /api/mail-signature — 계약 2절 "서명" */
export class UpdateMailSignatureDto {
@ApiPropertyOptional({
description:
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
nullable: true,
type: String,
example:
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
})
@IsOptional()
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
@ApiProperty({ description: '새 메일에 넣는다', example: true })
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
isForNew!: boolean;
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
isForReply!: boolean;
}
export class SignatureImageDto implements SignatureImage {
@ApiProperty({
description:
'그림 id — 서명 HTML 의 <img src="/api/mail-signature/images/{id}"> 에 쓴다',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
id!: string;
@ApiProperty({
description: '파일 내용으로 확인한 형식',
enum: IMAGE_TYPES,
example: 'image/png',
})
type!: SignatureImageType;
@ApiProperty({
description: `크기 (바이트, ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')} 이하)`,
example: 18342,
})
size!: number;
@ApiProperty({ description: '원본 가로 픽셀', example: 480 })
width!: number;
@ApiProperty({ description: '원본 세로 픽셀', example: 120 })
height!: number;
}
export class MailSignatureDto implements MailSignature {
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
nullable: true,
type: String,
})
body!: string | null;
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
isForNew!: boolean;
@ApiProperty({
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
example: true,
})
isForReply!: boolean;
@ApiProperty({
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
type: [SignatureImageDto],
})
images!: SignatureImageDto[];
@ApiProperty({
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
nullable: true,
type: String,
example: '2026-10-27T05:12:00.000Z',
})
updatedAt!: string | null;
}
@@ -0,0 +1,55 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** 'image/jpeg' 등 */
const CONTENT_TYPE_LENGTH = 32;
/** 서버가 만든 파일 이름 — `<uuid>.<확장자>` */
const FILE_NAME_LENGTH = 64;
/**
* 서명 그림 (계약 2절 "서명") — 파일은 업로드 볼륨의 하위 폴더에 두고(mail-signature.constants.ts), 여기에는 정보만 둔다.
* - 파일 이름은 서버가 만든다 (UUID + 내용으로 확인한 형식의 확장자). 올린 이름은 저장하지 않는다
* - `attached_at`: 서명을 저장할 때 그 HTML 이 가리키면 채운다. 비어 있고 하루가 지난 그림은 정리 작업이 지운다
*/
@Entity({ name: 'mail_signature_images' })
@Index('IDX_mail_signature_images_user_email', ['userEmail'])
@Index('IDX_mail_signature_images_unattached', ['createdAt'], {
where: '"attached_at" IS NULL',
})
export class MailSignatureImageEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
userEmail!: string;
/** 파일 내용으로 확인한 형식 (클라이언트가 보낸 값이 아니다) */
@Column({
name: 'content_type',
type: 'varchar',
length: CONTENT_TYPE_LENGTH,
})
contentType!: string;
/** 바이트 */
@Column({ type: 'integer' })
size!: number;
/** 원본 픽셀 — 파일 머리에서 읽었다 */
@Column({ type: 'integer' })
width!: number;
@Column({ type: 'integer' })
height!: number;
@Column({ name: 'file_name', type: 'varchar', length: FILE_NAME_LENGTH })
fileName!: string;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 저장한 서명이 처음 가리킨 시각 — null 이면 올리기만 한 그림 */
@Column({ name: 'attached_at', type: 'timestamptz', nullable: true })
attachedAt!: Date | null;
}
@@ -0,0 +1,144 @@
import { Logger } from '@nestjs/common';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
import type { MailSignatureRepository } from './mail-signature.repository';
import { SignatureImageStorage } from './signature-image-storage';
const NOW = new Date('2026-10-27T04:00:00Z');
const HOUR = 60 * 60 * 1000;
const idOf = (n: number) => `0000000${n}-0000-4000-8000-000000000000`;
describe('MailSignatureImageCleanup (하루 한 번 서명 그림 정리)', () => {
let dir: string;
let storage: SignatureImageStorage;
let repository: {
findUnattachedBefore: jest.Mock<
Promise<MailSignatureImageEntity[]>,
[Date]
>;
deleteUnattachedImages: jest.Mock<
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
[readonly string[]]
>;
listFileNames: jest.Mock<Promise<Set<string>>, []>;
};
let cleanup: MailSignatureImageCleanup;
/** 파일을 만들고 수정 시각을 `hoursAgo` 시간 전으로 둔다 */
function file(n: number, hoursAgo: number): string {
const fileName = `${idOf(n)}.png`;
const path = join(dir, fileName);
writeFileSync(path, 'png');
const at = new Date(NOW.getTime() - hoursAgo * HOUR);
utimesSync(path, at, at);
return fileName;
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-cleanup-'));
storage = new SignatureImageStorage(dir);
repository = {
findUnattachedBefore: jest
.fn<Promise<MailSignatureImageEntity[]>, [Date]>()
.mockResolvedValue([]),
deleteUnattachedImages: jest
.fn<
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
[readonly string[]]
>()
.mockImplementation((ids) =>
Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))),
),
listFileNames: jest
.fn<Promise<Set<string>>, []>()
.mockResolvedValue(new Set()),
};
cleanup = new MailSignatureImageCleanup(
repository as unknown as MailSignatureRepository,
storage,
);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('서명에 붙지 않은 채 하루가 지난 그림을 표와 파일에서 지운다', async () => {
const stale = file(1, 30);
repository.findUnattachedBefore.mockResolvedValue([
{ id: idOf(1), fileName: stale } as MailSignatureImageEntity,
]);
repository.listFileNames.mockResolvedValue(new Set());
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
expect(repository.findUnattachedBefore).toHaveBeenCalledWith(
new Date(NOW.getTime() - 24 * HOUR),
);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([idOf(1)]);
expect(await storage.list()).toEqual([]);
});
it('표에 없는 파일은 하루가 지났을 때만 지운다 — 표에 있는 파일·막 쓴 파일·다른 이름의 파일은 그대로', async () => {
const known = file(1, 48);
const orphan = file(2, 25);
const fresh = file(3, 1);
writeFileSync(join(dir, 'README.txt'), '그림이 아니다');
repository.listFileNames.mockResolvedValue(new Set([known]));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
const left = (await storage.list()).map((stored) => stored.fileName).sort();
expect(left).toEqual([known, fresh].sort());
expect(left).not.toContain(orphan);
});
it('찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다', async () => {
const stillUnattached = file(1, 30);
const attachedMeanwhile = file(2, 30);
repository.findUnattachedBefore.mockResolvedValue([
{ id: idOf(1), fileName: stillUnattached } as MailSignatureImageEntity,
{ id: idOf(2), fileName: attachedMeanwhile } as MailSignatureImageEntity,
]);
// 저장소는 지우는 순간 attached_at IS NULL 을 다시 본다 — 2번은 그사이 붙어 지우지 않았다
repository.deleteUnattachedImages.mockResolvedValue([
{ id: idOf(1), fileName: stillUnattached },
]);
repository.listFileNames.mockResolvedValue(new Set([attachedMeanwhile]));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([
idOf(1),
idOf(2),
]);
expect((await storage.list()).map((stored) => stored.fileName)).toEqual([
attachedMeanwhile,
]);
});
it('정리에 실패해도 던지지 않는다 — 오류 로그를 남기고 다음 주기에 다시 한다', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
repository.findUnattachedBefore.mockRejectedValue(new Error('db down'));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[SYS_001] 서명 그림 정리에 실패했습니다'),
);
});
it('지울 것이 없으면 아무것도 지우지 않는다 (폴더가 아직 없어도)', async () => {
rmSync(dir, { recursive: true, force: true });
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([]);
});
});
@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS } from './mail-signature.constants';
import { MailSignatureRepository } from './mail-signature.repository';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 서명 그림 정리 — 하루에 한 번 (auth-session.cleanup.ts 와 같은 모양).
* - 올리고 서명에 저장하지 않은 채 하루가 지난 그림을 지운다 (표와 파일)
* - 표에 없는 파일(올리다 끊긴 것 등)도 하루가 지났으면 지운다
*
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
*/
@Injectable()
export class MailSignatureImageCleanup {
private readonly logger = new Logger(MailSignatureImageCleanup.name);
constructor(
private readonly repository: MailSignatureRepository,
private readonly storage: SignatureImageStorage,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM, {
name: 'mail-signature-image-cleanup',
})
async purgeUnattachedImages(now: Date = new Date()): Promise<number> {
try {
const before = new Date(
now.getTime() - UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS,
);
// 찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다
const unattached = await this.repository.findUnattachedBefore(before);
const deleted = await this.repository.deleteUnattachedImages(
unattached.map((image) => image.id),
);
for (const image of deleted) await this.storage.remove(image.fileName);
const known = await this.repository.listFileNames();
const orphans = (await this.storage.list()).filter(
(file) => !known.has(file.fileName) && file.modifiedAt < before,
);
for (const file of orphans) await this.storage.remove(file.fileName);
const removed = deleted.length + orphans.length;
// 반복되는 정상 작업이라 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,57 @@
import { join } from 'path';
import { INLINE_IMAGE_MAX_BYTES } from '@/modules/mail/html/inline-images';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
/**
* 서명 한도 — 계약 2절 "서명"의 숫자를 한 곳에 모은다.
*/
/**
* 서명 그림 한 장 최대 크기 — 받은 메일의 내장 그림을 본문에 넣는 한도와 **같은 값**이다.
* 더 크게 받으면 보낸 메일을 우리 웹메일로 다시 읽을 때(보낸편지함·받는 사람) 그림이 빠진다 (html/inline-images.ts).
*/
export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
/**
* 서명 그림의 가로·세로 상한(픽셀, 각각) — 넘으면 올리기가 400 FILE_001.
* 300KB 안에도 수만×수만 픽셀(압축 폭탄)이 들 수 있고, PNG 머리의 가로·세로(32비트)는 표의 integer 열을 넘을 수 있다.
* 서명에 넣는 폭은 600px 까지라(보내는 정화의 width) 충분히 넉넉하다.
*/
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
/**
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
*/
export const SIGNATURE_IMAGES_STORED_MAX = 10;
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
/** 텍스트 서명 최대 길이 — JS 문자열 길이 (웹 클라이언트가 세는 방법과 같다) */
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000;
/** 올리고 서명에 저장하지 않은 그림을 지우기까지의 시간 */
export const UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
/**
* 서명 저장(PUT)이 지우지 않고 남겨 두는, 막 올린 그림의 나이.
* 올리기와 저장이 동시에 오면(그림을 넣자마자 [저장]) 저장 본문에는 아직 그 그림이 없다 — 그때 지우면
* 편집기에 넣은 그림이 사라진다. 이 시간보다 오래된, 한 번도 저장하지 않은 그림은 저장할 때 지운다
* (그 밖에는 하루 뒤 정리 작업이 지운다).
*/
export const SIGNATURE_IMAGE_UPLOAD_GRACE_MS = 10 * 60_000;
/**
* 서명 그림 파일 위치 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR) 안의 하위 폴더.
* 운영은 named volume, 개발은 바인드 마운트다 (.claude/skills/docker/SKILL.md).
*/
export const SIGNATURE_IMAGE_DIR = join(UPLOAD_DIR, 'mail-signatures');
/** 그림 응답의 브라우저 캐시 — 그림 id 는 내용이 바뀌지 않는다(새로 올리면 새 id). 본인만 보므로 private */
export const SIGNATURE_IMAGE_CACHE_CONTROL = 'private, max-age=86400';
@@ -0,0 +1,178 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Put,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBody,
ApiConsumes,
ApiCookieAuth,
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 { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailSignatureDto,
SignatureImageDto,
UpdateMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_CACHE_CONTROL,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureService } from './mail-signature.service';
import { SIGNATURE_IMAGE_EXTENSIONS } from './signature-image-format';
import { SignatureImageIdPipe } from './signature-image-id.pipe';
/** 서명 그림 응답 형식 — 저장할 때 파일 내용으로 확인한 형식 그대로 */
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signature')
export class MailSignatureController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '내 서명',
description:
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
getSignature(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getSignature(user.email);
}
@Put()
@ApiOperation({
summary: '서명 저장 (전체 교체)',
description:
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
@ApiErrorResponse(
'VAL_001',
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
)
saveSignature(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateMailSignatureDto,
) {
return this.signatures.saveSignature(user.email, dto);
}
@Post('images')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({
summary: '서명 그림 올리기 (파일 1개)',
description:
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
'서명을 저장하지 않으면 하루 뒤 지운다.',
})
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: { file: { type: 'string', format: 'binary' } },
required: ['file'],
},
})
@ApiStandardResponse(SignatureImageDto, {
status: HttpStatus.CREATED,
description: '올린 그림 — 이 id 를 서명 HTML 의 img 주소에 쓴다',
})
@ApiErrorResponse(
'FILE_001',
'허용되지 않는 형식 · 내용이 확장자·MIME 과 다른 형식 (400)',
)
@ApiErrorResponse(
'VAL_001',
`파일 없음 · 둘 수 있는 그림 ${SIGNATURE_IMAGES_STORED_MAX}장 초과 (400)`,
)
@ApiErrorResponse('FILE_002', '한 장 300KB 초과')
uploadImage(
@CurrentUser() user: AuthenticatedUser,
@UploadedFile() file?: Express.Multer.File,
) {
if (!file?.buffer) {
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
context: { reason: 'signature-image-file-missing' },
});
}
return this.signatures.uploadImage(user.email, {
originalName: file.originalname,
mimeType: file.mimetype,
data: file.buffer,
});
}
@Get('images/:id')
@SkipTransform()
@ApiOperation({
summary: '서명 그림 (표준 래퍼 없음, 내 그림만)',
description:
'편집기·미리 보기용 — 받는 사람은 이 주소를 쓰지 않는다(보낼 때 메일 안에 함께 싣는다). ' +
'형식은 저장할 때 확인한 것, X-Content-Type-Options: nosniff, Content-Disposition: inline, 캐시는 private.',
})
@ApiProduces(...IMAGE_CONTENT_TYPES)
@ApiResponse({
status: HttpStatus.OK,
description: '그림 파일',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse('RES_001', '없는 그림 · 남의 그림 · 형식이 틀린 id')
async downloadImage(
@CurrentUser() user: AuthenticatedUser,
@Param('id', SignatureImageIdPipe) id: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const image = await this.signatures.openImage(user.email, id);
// 본인만 보는 그림 — 공유 캐시에 남기지 않는다. 내용은 바뀌지 않으므로(새로 올리면 새 id) 브라우저는 하루 둔다
res.setHeader('Cache-Control', SIGNATURE_IMAGE_CACHE_CONTROL);
res.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(image.data, {
type: image.type,
disposition: `inline; filename="signature.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}"`,
length: image.data.length,
});
}
}
@@ -0,0 +1,36 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
*/
@Entity({ name: 'mail_signatures' })
export class MailSignatureEntity {
@PrimaryColumn({
name: 'user_email',
type: 'varchar',
length: EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
@Column({ name: 'body_html', type: 'text', nullable: true })
bodyHtml!: string | null;
@Column({ name: 'body_text', type: 'text', nullable: true })
bodyText!: string | null;
/** 새 메일에 넣는다 */
@Column({ name: 'is_for_new', type: 'boolean', default: true })
isForNew!: boolean;
/** 답장·전달에 넣는다 (인용 위) */
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
isForReply!: boolean;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,62 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { memoryStorage } from 'multer';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
import { SIGNATURE_IMAGE_MAX_BYTES } from './mail-signature.constants';
import { MailSignatureController } from './mail-signature.controller';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
*
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
* 형식 사전 검사는 확장자 + MIME 짝(FILE_001), 크기 초과는 multer → 413(FILE_002).
*/
@Module({
imports: [
TypeOrmModule.forFeature([MailSignatureEntity, MailSignatureImageEntity]),
MulterModule.register({
storage: memoryStorage(),
limits: { fileSize: SIGNATURE_IMAGE_MAX_BYTES, files: 1, fields: 10 },
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
defParamCharset: 'utf8',
fileFilter: (_req, file, callback) => {
const type = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
extname(file.originalname).toLowerCase(),
);
if (!type || type !== file.mimetype) {
callback(
new BusinessException('FILE_001', {
context: { reason: 'signature-image-type-not-allowed' },
}),
false,
);
return;
}
callback(null, true);
},
}),
],
controllers: [MailSignatureController],
providers: [
MailSignatureRepository,
{
provide: SignatureImageStorage,
useFactory: () => new SignatureImageStorage(),
},
MailSignatureService,
MailSignatureImageCleanup,
],
exports: [MailSignatureService],
})
export class MailSignatureModule {}
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureEntity } from './mail-signature.entity';
/**
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
*/
const SIGNATURE_SAVE_LOCK = 0x53494731;
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
export type PrepareSignature = (
images: readonly MailSignatureImageEntity[],
) => {
readonly signature: MailSignatureEntity;
readonly referencedImageIds: readonly string[];
};
/** 서명 저장 결과 */
export interface SavedSignature {
readonly signature: MailSignatureEntity;
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
readonly images: MailSignatureImageEntity[];
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
readonly removed: MailSignatureImageEntity[];
}
/**
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
*/
@Injectable()
export class MailSignatureRepository {
constructor(
@InjectRepository(MailSignatureEntity)
private readonly signatures: Repository<MailSignatureEntity>,
@InjectRepository(MailSignatureImageEntity)
private readonly images: Repository<MailSignatureImageEntity>,
private readonly dataSource: DataSource,
) {}
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
return this.signatures.findOneBy({ userEmail });
}
/** 그 사람의 그림 — 올린 순서대로 */
listImages(userEmail: string): Promise<MailSignatureImageEntity[]> {
return this.images.find({
where: { userEmail },
order: { createdAt: 'ASC' },
});
}
findImage(
id: string,
userEmail: string,
): Promise<MailSignatureImageEntity | null> {
return this.images.findOneBy({ id, userEmail });
}
countImages(userEmail: string): Promise<number> {
return this.images.countBy({ userEmail });
}
async insertImage(image: MailSignatureImageEntity): Promise<void> {
await this.images.insert(image);
}
/**
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
*/
saveSignature(
userEmail: string,
prepare: PrepareSignature,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
return this.dataSource.transaction(async (manager) => {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
SIGNATURE_SAVE_LOCK,
userEmail,
]);
const images = await manager.find(MailSignatureImageEntity, {
where: { userEmail },
order: { createdAt: 'ASC' },
lock: { mode: 'pessimistic_write' },
});
const { signature, referencedImageIds } = prepare(images);
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
const referenced = new Set(referencedImageIds);
const attach = images
.filter(
(image) => referenced.has(image.id) && image.attachedAt === null,
)
.map((image) => image.id);
const stale = images.filter(
(image) =>
!referenced.has(image.id) &&
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
);
if (attach.length > 0) {
await manager.update(
MailSignatureImageEntity,
{ id: In(attach) },
{ attachedAt: now },
);
}
if (stale.length > 0) {
await manager.delete(MailSignatureImageEntity, {
id: In(stale.map((image) => image.id)),
});
}
const attached = new Set(attach);
return {
signature,
images: images
.filter((image) => !stale.includes(image))
.map((image) =>
attached.has(image.id) ? { ...image, attachedAt: now } : image,
),
removed: stale,
};
});
}
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
return this.images.find({
where: { attachedAt: IsNull(), createdAt: LessThan(before) },
});
}
/**
* 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다).
* 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다).
* @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다
*/
async deleteUnattachedImages(
ids: readonly string[],
): Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]> {
if (ids.length === 0) return [];
const result = await this.images
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: [...ids] })
.andWhere('attached_at IS NULL')
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING "id", "file_name" (행의 키는 열 이름)
.returning(['id', 'fileName'])
.execute();
const rows = (result.raw ?? []) as { id: string; file_name: string }[];
return rows.map((row) => ({ id: row.id, fileName: row.file_name }));
}
/** 표에 있는 모든 그림의 파일 이름 (정리 작업이 표에 없는 파일을 찾는다) */
async listFileNames(): Promise<Set<string>> {
const rows = await this.images.find({ select: { fileName: true } });
return new Set(rows.map((row) => row.fileName));
}
}
@@ -0,0 +1,577 @@
import { Logger } from '@nestjs/common';
import { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_MAX_DIMENSION,
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import type {
MailSignatureRepository,
PrepareSignature,
SavedSignature,
} from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import { SignatureImageStorage } from './signature-image-storage';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const src = (id: string) => `/api/mail-signature/images/${id}`;
/**
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
*/
class FakeRepository {
readonly signatures = new Map<string, MailSignatureEntity>();
images: MailSignatureImageEntity[] = [];
/** 다음 insertImage 를 실패시킨다 */
failNextInsert = false;
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextSave: string | null = null;
readonly saveCalls: {
referenced: readonly string[];
keepUploadedAfter: Date;
}[] = [];
findSignature(user: string): Promise<MailSignatureEntity | null> {
return Promise.resolve(this.signatures.get(user) ?? null);
}
listImages(user: string): Promise<MailSignatureImageEntity[]> {
return Promise.resolve(this.images.filter((row) => row.userEmail === user));
}
findImage(
id: string,
user: string,
): Promise<MailSignatureImageEntity | null> {
return Promise.resolve(
this.images.find((row) => row.id === id && row.userEmail === user) ??
null,
);
}
countImages(user: string): Promise<number> {
return Promise.resolve(
this.images.filter((row) => row.userEmail === user).length,
);
}
insertImage(row: MailSignatureImageEntity): Promise<void> {
if (this.failNextInsert) {
this.failNextInsert = false;
return Promise.reject(new Error('db down'));
}
this.images.push(row);
return Promise.resolve();
}
saveSignature(
user: string,
prepare: PrepareSignature,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
if (this.deletedBeforeNextSave) {
const gone = this.deletedBeforeNextSave;
this.deletedBeforeNextSave = null;
this.images = this.images.filter((row) => row.id !== gone);
}
const mine = this.images.filter((row) => row.userEmail === user);
const { signature, referencedImageIds: referenced } = prepare(mine);
this.saveCalls.push({ referenced, keepUploadedAfter });
this.signatures.set(signature.userEmail, signature);
for (const row of mine) {
if (referenced.includes(row.id) && row.attachedAt === null) {
row.attachedAt = now;
}
}
const stale = mine.filter(
(row) =>
!referenced.includes(row.id) &&
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
);
this.images = this.images.filter((row) => !stale.includes(row));
return Promise.resolve({
signature,
images: mine.filter((row) => !stale.includes(row)),
removed: stale,
});
}
}
describe('MailSignatureService', () => {
let dir: string;
let repository: FakeRepository;
let storage: SignatureImageStorage;
let service: MailSignatureService;
let warnSpy: jest.SpyInstance;
const upload = (data: Buffer, name = 'logo.png', mimeType = 'image/png') =>
service.uploadImage(ARA, { originalName: name, mimeType, data });
async function failureOf(
run: () => Promise<unknown>,
): Promise<{ code?: string; reason?: unknown }> {
try {
await run();
} catch (error) {
return error instanceof BusinessException
? { code: error.code, reason: error.context?.reason }
: { code: 'NOT_BUSINESS' };
}
return {};
}
/** 이미 올려 둔 그림 한 장 (표 + 파일) */
async function storedImage(
overrides: Partial<MailSignatureImageEntity> = {},
): Promise<MailSignatureImageEntity> {
const id =
overrides.id ??
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
const row: MailSignatureImageEntity = {
id,
userEmail: ARA,
contentType: 'image/png',
size: 33,
width: 10,
height: 10,
fileName: `${id}.png`,
createdAt: new Date('2026-10-26T00:00:00Z'),
attachedAt: null,
...overrides,
};
repository.images.push(row);
await storage.write(row.fileName, pngBytes(row.width, row.height));
return row;
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
repository = new FakeRepository();
storage = new SignatureImageStorage(dir);
service = new MailSignatureService(
repository as unknown as MailSignatureRepository,
storage,
);
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('저장한 적 없으면 기본값을 돌려준다', async () => {
await expect(service.getSignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
});
});
describe('저장', () => {
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
const mine = await storedImage();
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
bodyHtml: html,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
});
expect(saved).toEqual({
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
images: [
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
],
updatedAt: expect.any(String) as string,
});
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
expect(repository.signatures.has(ARA)).toBe(true);
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
});
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
await service.saveSignature(ARA, {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
});
const { keepUploadedAfter } = repository.saveCalls[0];
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
});
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
const kept = await storedImage();
const gone = await storedImage();
repository.deletedBeforeNextSave = gone.id;
const saved = await service.saveSignature(ARA, {
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toBe(
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
);
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
repository.deletedBeforeNextSave = kept.id;
const empty = await service.saveSignature(ARA, {
bodyHtml: `<img src="${src(kept.id)}">`,
body: '텍스트 서명',
isForNew: true,
isForReply: true,
});
expect(empty.bodyHtml).toBeNull();
expect(empty.body).toBe('텍스트 서명');
});
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
const kept = await storedImage({ attachedAt: new Date() });
const dropped = await storedImage({ attachedAt: new Date() });
await service.saveSignature(ARA, {
bodyHtml: `<img src="${src(kept.id)}">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
expect(existsSync(join(dir, kept.fileName))).toBe(true);
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
});
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
const dropped = await storedImage({ attachedAt: new Date() });
jest
.spyOn(storage, 'remove')
.mockRejectedValue(new Error('EACCES: permission denied'));
await expect(
service.saveSignature(ARA, {
bodyHtml: '<p>그림 없음</p>',
body: null,
isForNew: true,
isForReply: true,
}),
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
expect(repository.images).not.toContainEqual(dropped);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('서명 그림 파일을 지우지 못했습니다'),
);
});
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
const saved = await service.saveSignature(ARA, {
bodyHtml,
body: '',
isForNew: false,
isForReply: true,
});
expect(saved.bodyHtml).toBeNull();
expect(saved.body).toBeNull();
}
});
it(`서명 하나에 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (같은 그림 두 번은 한 장 · 남의 그림은 정화가 지워 세지 않는다)`, async () => {
const images: MailSignatureImageEntity[] = [];
for (
let index = 0;
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
index += 1
) {
images.push(await storedImage());
}
const img = (id: string) => `<img src="${src(id)}" width="96">`;
const save = (bodyHtml: string) =>
service.saveSignature(ARA, {
bodyHtml,
body: null,
isForNew: true,
isForReply: true,
});
expect(
await failureOf(() =>
save(images.map((image) => img(image.id)).join('')),
),
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
expect(repository.saveCalls).toHaveLength(0);
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
const [a, b, c] = images;
const saved = await save(
[a, b, c, a].map((image) => img(image.id)).join('') +
img('11111111-1111-4111-8111-111111111111'),
);
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
});
it('한도를 넘으면 400 VAL_001 — 저장하지 않는다 (텍스트 2,000자 · 정화한 서식 10,000자 · 정화 전 길이·비용)', async () => {
const cases: [Record<string, string>, string][] = [
[{ body: '가'.repeat(2_001) }, 'signature-text-too-long'],
[
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
'signature-html-too-long',
],
[
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
'signature-html-too-long',
],
[{ bodyHtml: '<b>'.repeat(166_666) }, 'signature-html-too-complex'],
];
for (const [input, reason] of cases) {
expect(
await failureOf(() =>
service.saveSignature(ARA, {
isForNew: true,
isForReply: true,
...input,
}),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(repository.saveCalls).toHaveLength(0);
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
const saved = await service.saveSignature(ARA, {
body: '가'.repeat(2_000),
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toHaveLength(10_000);
});
});
describe('그림 올리기', () => {
it('PNG 를 내용으로 확인하고 서버가 만든 이름으로 저장한다 — 가로·세로는 파일 머리에서 읽는다', async () => {
const data = pngBytes(480, 120, 2_048);
const image = await upload(data, '로고 (최종).PNG');
expect(image).toEqual({
id: expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
) as string,
type: 'image/png',
size: 2_048,
width: 480,
height: 120,
});
const [row] = repository.images;
expect(row).toMatchObject({
userEmail: ARA,
fileName: `${image.id}.png`,
attachedAt: null,
});
expect(existsSync(join(dir, `${image.id}.png`))).toBe(true);
});
it('내용이 확장자·MIME 과 다른 형식이면 FILE_001 — 아무것도 저장하지 않는다', async () => {
for (const [data, name, mimeType] of [
// PNG 내용인데 이름은 JPEG
[pngBytes(10, 10), 'logo.jpg', 'image/jpeg'],
// GIF 내용인데 PNG 라고 한다
[gifBytes(10, 10), 'logo.png', 'image/png'],
// 그림이 아니다
[Buffer.from('<svg onload="x()"></svg>'), 'logo.png', 'image/png'],
// 이름과 MIME 이 서로 다르다
[pngBytes(10, 10), 'logo.png', 'image/gif'],
] as const) {
expect(await failureOf(() => upload(data, name, mimeType))).toEqual({
code: 'FILE_001',
reason: 'signature-image-content-mismatch',
});
}
expect(repository.images).toHaveLength(0);
});
it(`가로·세로가 각각 ${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀을 넘으면 FILE_001 — 아무것도 저장하지 않는다 (작은 파일 안의 거대한 그림 · 32비트 PNG 값)`, async () => {
for (const [width, height] of [
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 10],
[10, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
// 표의 integer(2^31-1)를 넘는 PNG 가로 — 예전에는 INSERT 가 500 이 됐다
[2 ** 31, 10],
[2 ** 32 - 1, 2 ** 32 - 1],
]) {
expect(await failureOf(() => upload(pngBytes(width, height)))).toEqual({
code: 'FILE_001',
reason: 'signature-image-dimensions',
});
}
expect(repository.images).toHaveLength(0);
expect(await storage.list()).toEqual([]);
await expect(
upload(
pngBytes(
SIGNATURE_IMAGE_MAX_DIMENSION,
SIGNATURE_IMAGE_MAX_DIMENSION,
),
),
).resolves.toMatchObject({
width: SIGNATURE_IMAGE_MAX_DIMENSION,
height: SIGNATURE_IMAGE_MAX_DIMENSION,
});
});
it('300KB 를 넘으면 FILE_002 (multer 가 먼저 막지만 한 번 더)', async () => {
expect(
await failureOf(() =>
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1)),
),
).toEqual({ code: 'FILE_002', reason: 'signature-image-too-large' });
await expect(
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES)),
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
});
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
const results = await Promise.allSettled(
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
upload(pngBytes(10, 10)),
),
);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
SIGNATURE_IMAGES_STORED_MAX,
);
const rejected = results.flatMap((result): unknown[] =>
result.status === 'rejected' ? [result.reason] : [],
);
expect(rejected).toHaveLength(2);
for (const reason of rejected) {
expect(reason).toBeInstanceOf(BusinessException);
expect((reason as BusinessException).code).toBe('VAL_001');
expect((reason as BusinessException).context?.reason).toBe(
'signature-image-storage-full',
);
}
expect(repository.images).toHaveLength(SIGNATURE_IMAGES_STORED_MAX);
// 다른 사람은 따로 센다
await expect(
service.uploadImage(DOYUN, {
originalName: 'logo.png',
mimeType: 'image/png',
data: pngBytes(10, 10),
}),
).resolves.toMatchObject({ type: 'image/png' });
});
it('표에 넣지 못하면 쓴 파일을 지우고 실패를 그대로 던진다 — 다음 올리기는 된다', async () => {
repository.failNextInsert = true;
expect(await failureOf(() => upload(pngBytes(10, 10)))).toEqual({
code: 'NOT_BUSINESS',
});
expect(repository.images).toHaveLength(0);
expect(await storage.list()).toEqual([]);
await expect(upload(pngBytes(10, 10))).resolves.toMatchObject({
type: 'image/png',
});
});
});
describe('그림 읽기', () => {
it('주인에게는 파일과 형식을 준다 — 남의 그림·없는 그림은 RES_001', async () => {
const mine = await storedImage({ width: 7, height: 3 });
await expect(service.openImage(ARA, mine.id)).resolves.toEqual({
data: pngBytes(7, 3),
type: 'image/png',
});
for (const [user, id] of [
[DOYUN, mine.id],
[ARA, '11111111-1111-4111-8111-111111111111'],
]) {
expect(await failureOf(() => service.openImage(user, id))).toEqual({
code: 'RES_001',
reason: 'signature-image-not-found',
});
}
});
it('표에는 있는데 파일이 없으면 RES_001 — 경고를 남긴다', async () => {
const mine = await storedImage();
await storage.remove(mine.fileName);
expect(await failureOf(() => service.openImage(ARA, mine.id))).toEqual({
code: 'RES_001',
reason: 'signature-image-not-found',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('서명 그림 파일이 없습니다'),
);
});
it('발송용 — 내 그림 id 전부, 그리고 요청한 순서대로 내 그림만 읽는다 (파일이 없는 그림은 빼고 경고)', async () => {
const first = await storedImage();
const second = await storedImage({ attachedAt: new Date() });
const missing = await storedImage();
await storage.remove(missing.fileName);
const others = await storedImage({ userEmail: DOYUN });
await expect(service.imageIdsOf(ARA)).resolves.toEqual(
new Set([first.id, second.id, missing.id]),
);
const loaded = await service.loadImages(ARA, [
second.id,
others.id,
missing.id,
first.id,
]);
expect(loaded.map((image) => image.id)).toEqual([second.id, first.id]);
expect(loaded[0]).toEqual({
id: second.id,
type: 'image/png',
data: pngBytes(10, 10),
});
expect(warnSpy).toHaveBeenCalledTimes(2);
await expect(service.loadImages(ARA, [])).resolves.toEqual([]);
});
});
});
@@ -0,0 +1,372 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
import {
findSignatureImageIds,
rewriteSignatureImages,
signatureImagePath,
} from '@/modules/mail/html/signature-images';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import type {
MailSignature,
MailSignatureInput,
SignatureImage,
SignatureImageUpload,
StoredSignatureImage,
} from './mail-signature.types';
import {
SIGNATURE_IMAGE_EXTENSIONS,
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
type SignatureImageType,
detectSignatureImage,
isWithinSignatureImageDimensions,
} from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
updatedAt: null,
};
function normalizeUser(email: string): string {
return email.trim().toLowerCase();
}
function toImage(row: MailSignatureImageEntity): SignatureImage {
return {
id: row.id,
type: row.contentType as SignatureImageType,
size: row.size,
width: row.width,
height: row.height,
};
}
/** 올린 이름의 확장자 (소문자, 점 포함) */
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
}
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
*/
@Injectable()
export class MailSignatureService {
private readonly logger = new Logger(MailSignatureService.name);
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
private readonly uploadQueues = new Map<string, Promise<void>>();
constructor(
private readonly repository: MailSignatureRepository,
private readonly storage: SignatureImageStorage,
) {}
async getSignature(userEmail: string): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const [row, images] = await Promise.all([
this.repository.findSignature(user),
this.repository.listImages(user),
]);
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
return this.toSignature(row, images);
}
/**
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
*/
async saveSignature(
userEmail: string,
input: MailSignatureInput,
): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const body = input.body ? input.body : null;
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-text-too-long', length: body.length },
});
}
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-too-many-images',
count: referenced.length,
},
});
}
const now = new Date();
const saved = await this.repository.saveSignature(
user,
(images) => {
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
const existing = new Set(images.map((image) => image.id));
const html =
bodyHtml === null
? null
: rewriteSignatureImages(bodyHtml, (id) =>
existing.has(id) ? signatureImagePath(id) : null,
);
const kept = html !== null && html.trim() !== '' ? html : null;
return {
signature: {
userEmail: user,
bodyHtml: kept,
bodyText: body,
isForNew: input.isForNew,
isForReply: input.isForReply,
updatedAt: now,
},
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
};
},
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(saved.removed, user);
return this.toSignature(saved.signature, saved.images);
}
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
uploadImage(
userEmail: string,
upload: SignatureImageUpload,
): Promise<SignatureImage> {
const user = normalizeUser(userEmail);
const info = detectSignatureImage(upload.data);
const declared = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
extensionOf(upload.originalName),
);
if (!info || info.type !== declared || info.type !== upload.mimeType) {
return Promise.reject(
new BusinessException('FILE_001', {
context: { reason: 'signature-image-content-mismatch' },
}),
);
}
// 가로·세로 각각 4096 픽셀까지 — 작은 파일 안의 거대한 그림(압축 폭탄)·표의 integer 를 넘는 값을 받지 않는다
if (!isWithinSignatureImageDimensions(info)) {
return Promise.reject(
new BusinessException('FILE_001', {
context: {
reason: 'signature-image-dimensions',
width: info.width,
height: info.height,
},
}),
);
}
if (upload.data.length > SIGNATURE_IMAGE_MAX_BYTES) {
return Promise.reject(
new BusinessException('FILE_002', {
context: {
reason: 'signature-image-too-large',
size: upload.data.length,
},
}),
);
}
return this.inUploadQueue(user, async () => {
const count = await this.repository.countImages(user);
if (count >= SIGNATURE_IMAGES_STORED_MAX) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-image-storage-full', count },
});
}
const id = randomUUID();
const row: MailSignatureImageEntity = {
id,
userEmail: user,
contentType: info.type,
size: upload.data.length,
width: info.width,
height: info.height,
fileName: `${id}.${SIGNATURE_IMAGE_EXTENSIONS[info.type]}`,
createdAt: new Date(),
attachedAt: null,
};
await this.storage.write(row.fileName, upload.data);
try {
await this.repository.insertImage(row);
} catch (error) {
// 표에 넣지 못한 파일은 남기지 않는다
await this.storage.remove(row.fileName).catch(() => undefined);
throw error;
}
return toImage(row);
});
}
/** 그 사람의 그림 파일 — 남의 그림·없는 그림은 RES_001 */
async openImage(
userEmail: string,
id: string,
): Promise<{ data: Buffer; type: SignatureImageType }> {
const user = normalizeUser(userEmail);
const row = await this.repository.findImage(id, user);
const data = row ? await this.storage.read(row.fileName) : null;
if (!row || !data) {
if (row) {
this.logger.warn(
`[RES_001]${traceTag()} 서명 그림 파일이 없습니다: user=${maskEmail(user)}, id=${id}`,
);
}
throw new BusinessException('RES_001', {
context: { reason: 'signature-image-not-found' },
});
}
return { data, type: row.contentType as SignatureImageType };
}
/** 보내는 정화가 남겨도 되는 그림 id — 그 사람의 그림 전부 (서명에 붙었든 아니든) */
async imageIdsOf(userEmail: string): Promise<ReadonlySet<string>> {
const images = await this.repository.listImages(normalizeUser(userEmail));
return new Set(images.map((image) => image.id));
}
/**
* 메일에 함께 실을 그림을 읽는다 — 그 사람의 그림만, 요청한 순서대로. 파일이 없는 그림은 빼고 경고만 남긴다
* (부르는 쪽이 그 img 를 지운다).
*/
async loadImages(
userEmail: string,
ids: readonly string[],
): Promise<StoredSignatureImage[]> {
if (ids.length === 0) return [];
const user = normalizeUser(userEmail);
const rows = new Map(
(await this.repository.listImages(user)).map((row) => [row.id, row]),
);
const loaded: StoredSignatureImage[] = [];
for (const id of ids) {
const row = rows.get(id);
const data = row ? await this.storage.read(row.fileName) : null;
if (!row || !data) {
this.logger.warn(
`[RES_001]${traceTag()} 서명 그림을 메일에 싣지 못해 뺍니다: user=${maskEmail(user)}, id=${id}`,
);
continue;
}
loaded.push({ id, type: row.contentType as SignatureImageType, data });
}
return loaded;
}
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
private async sanitizeSignatureHtml(
user: string,
html: string | null | undefined,
): Promise<string | null> {
if (!html) return null;
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
if (html.length > MAX_BODY_HTML_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-html-too-long', length: html.length },
});
}
const budget = checkHtmlParseBudget(html);
if (!budget.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-complex',
detail: budget.reason,
},
});
}
const sanitized = sanitizeOutgoingHtml(html, {
signatureImageIds: await this.imageIdsOf(user),
});
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-long',
length: sanitized.length,
},
});
}
return sanitized === '' ? null : sanitized;
}
private toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
): MailSignature {
const byId = new Map(images.map((image) => [image.id, image]));
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
return {
bodyHtml: row.bodyHtml,
body: row.bodyText,
isForNew: row.isForNew,
isForReply: row.isForReply,
images: used.flatMap((id) => {
const image = byId.get(id);
return image ? [toImage(image)] : [];
}),
updatedAt: row.updatedAt.toISOString(),
};
}
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
private async removeFiles(
removed: readonly MailSignatureImageEntity[],
user: string,
): Promise<void> {
for (const image of removed) {
try {
await this.storage.remove(image.fileName);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 서명 그림 파일을 지우지 못했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
/** 같은 사용자의 작업을 차례로 돌린다 (앞 작업이 실패해도 다음 작업은 돈다) */
private inUploadQueue<T>(user: string, task: () => Promise<T>): Promise<T> {
const previous = this.uploadQueues.get(user) ?? Promise.resolve();
const run = previous.then(task, task);
const tail = run.then(
() => undefined,
() => undefined,
);
this.uploadQueues.set(user, tail);
void tail.then(() => {
if (this.uploadQueues.get(user) === tail) this.uploadQueues.delete(user);
});
return run;
}
}
@@ -0,0 +1,50 @@
import type { SignatureImageType } from './signature-image-format';
/**
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
*/
export interface SignatureImage {
readonly id: string;
readonly type: SignatureImageType;
/** 바이트 */
readonly size: number;
/** 원본 픽셀 */
readonly width: number;
readonly height: number;
}
export interface MailSignature {
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
readonly body: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 저장한 적 없으면 null */
readonly updatedAt: string | null;
}
/** PUT /api/mail-signature 본문 */
export interface MailSignatureInput {
readonly bodyHtml?: string | null;
readonly body?: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
}
/** 메일에 함께 실을 그림 — 발송 때 읽는다 */
export interface StoredSignatureImage {
readonly id: string;
readonly type: SignatureImageType;
readonly data: Buffer;
}
/** 올린 그림 파일 (multer 가 메모리에 받은 것 — 한 장 300KB 이하) */
export interface SignatureImageUpload {
readonly originalName: string;
readonly mimeType: string;
readonly data: Buffer;
}
@@ -0,0 +1,131 @@
import {
gifBytes as gif,
jpegBytes as jpeg,
pngBytes as png,
} from '@/../test/image-fixtures';
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
import {
detectSignatureImage,
isWithinSignatureImageDimensions,
} from './signature-image-format';
describe('isWithinSignatureImageDimensions (서명 그림 크기 상한)', () => {
it(`가로·세로가 각각 1~${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀이면 받는다`, () => {
expect(isWithinSignatureImageDimensions({ width: 1, height: 1 })).toBe(
true,
);
expect(
isWithinSignatureImageDimensions({
width: SIGNATURE_IMAGE_MAX_DIMENSION,
height: SIGNATURE_IMAGE_MAX_DIMENSION,
}),
).toBe(true);
});
it('하나라도 넘거나 0 이면 받지 않는다 — PNG 머리의 32비트 값(표의 integer 를 넘는다) 포함', () => {
for (const [width, height] of [
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 1],
[1, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
[0, 10],
[2 ** 31, 10],
]) {
expect(isWithinSignatureImageDimensions({ width, height })).toBe(false);
}
// PNG 는 머리에 32비트로 적는다 — 읽기는 하고(형식은 맞다) 크기 검사가 거른다
const huge = detectSignatureImage(png(2 ** 32 - 1, 2 ** 32 - 1));
expect(huge).toEqual({
type: 'image/png',
width: 2 ** 32 - 1,
height: 2 ** 32 - 1,
});
expect(huge && isWithinSignatureImageDimensions(huge)).toBe(false);
});
});
describe('detectSignatureImage (서명 그림 형식·크기)', () => {
it('PNG·GIF(87a·89a)·JPEG(기본·점진) 의 형식과 가로·세로를 읽는다', () => {
expect(detectSignatureImage(png(640, 120))).toEqual({
type: 'image/png',
width: 640,
height: 120,
});
expect(detectSignatureImage(gif(300, 80))).toEqual({
type: 'image/gif',
width: 300,
height: 80,
});
expect(detectSignatureImage(gif(1, 2, 'GIF87a'))).toEqual({
type: 'image/gif',
width: 1,
height: 2,
});
expect(detectSignatureImage(jpeg(1024, 256))).toEqual({
type: 'image/jpeg',
width: 1024,
height: 256,
});
// 점진(SOF2) · 표지 앞 채움 바이트
expect(
detectSignatureImage(jpeg(96, 48, { sof: 0xc2, fill: true })),
).toEqual({ type: 'image/jpeg', width: 96, height: 48 });
});
it('내용이 그 형식이 아니면 받지 않는다 — WebP·SVG·HTML·빈 파일', () => {
for (const data of [
Buffer.from('RIFF\x00\x00\x00\x00WEBPVP8 ', 'binary'),
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
Buffer.from('<html><script>alert(1)</script></html>'),
Buffer.alloc(0),
]) {
expect(detectSignatureImage(data)).toBeNull();
}
});
it('머리가 잘렸거나 망가졌거나 크기가 0 이면 받지 않는다', () => {
expect(detectSignatureImage(png(10, 10).subarray(0, 20))).toBeNull();
expect(detectSignatureImage(png(0, 10))).toBeNull();
expect(detectSignatureImage(gif(10, 0))).toBeNull();
expect(detectSignatureImage(gif(10, 10).subarray(0, 8))).toBeNull();
expect(detectSignatureImage(jpeg(0, 10))).toBeNull();
// IHDR 가 첫 덩어리가 아니다
const broken = png(10, 10);
broken.write('IDAT', 12, 'ascii');
expect(detectSignatureImage(broken)).toBeNull();
// JPEG: SOF 전에 그림 자료(SOS)가 오거나, 조각 길이가 파일 밖을 가리킨다
expect(
detectSignatureImage(
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]),
),
).toBeNull();
expect(
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x7f, 0xff])),
).toBeNull();
expect(
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xd9])),
).toBeNull();
});
it('긴 JPEG 도 조각 길이를 따라 앞으로만 간다 (입력 길이에 비례)', () => {
// 64KB 짜리 주석(COM) 조각 4개 뒤에 SOF
const comment = Buffer.alloc(0xffff + 2);
comment[0] = 0xff;
comment[1] = 0xfe;
comment.writeUInt16BE(0xffff, 2);
const data = Buffer.concat([
Buffer.from([0xff, 0xd8]),
comment,
comment,
comment,
comment,
jpeg(20, 10).subarray(2),
]);
expect(detectSignatureImage(data)).toEqual({
type: 'image/jpeg',
width: 20,
height: 10,
});
});
});
@@ -0,0 +1,161 @@
/**
* 서명 그림 형식 확인 (순수 함수) — 파일 앞부분(매직 바이트)으로 형식을 알아내고 머리에서 가로·세로 픽셀을 읽는다.
* 확장자·MIME 은 클라이언트가 정하므로 믿지 않는다 — 이름이 .png 여도 내용이 PNG 가 아니면 받지 않는다 (.claude/rules/security.md).
* 그림을 풀지(디코드) 않는다 — 머리만 읽으므로 네이티브 라이브러리·새 의존성이 필요 없다.
*/
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif';
export interface SignatureImageInfo {
readonly type: SignatureImageType;
readonly width: number;
readonly height: number;
}
/**
* 받을 수 있는 크기인가 — 가로·세로가 각각 1~SIGNATURE_IMAGE_MAX_DIMENSION(4096) 픽셀.
* (0 은 detectSignatureImage 가 이미 거른다)
*/
export function isWithinSignatureImageDimensions(
info: Pick<SignatureImageInfo, 'width' | 'height'>,
): boolean {
return (
info.width >= 1 &&
info.height >= 1 &&
info.width <= SIGNATURE_IMAGE_MAX_DIMENSION &&
info.height <= SIGNATURE_IMAGE_MAX_DIMENSION
);
}
/** 형식별 저장 확장자 — 서버가 만드는 파일 이름에 쓴다 */
export const SIGNATURE_IMAGE_EXTENSIONS: Readonly<
Record<SignatureImageType, string>
> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
};
/** 받는 확장자 → 형식 (업로드 사전 검사 — 확장자와 MIME 이 이 짝이어야 한다) */
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: ReadonlyMap<
string,
SignatureImageType
> = new Map([
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'],
]);
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
/** PNG 첫 덩어리 IHDR — 길이(4) 'IHDR'(4) 가로(4) 세로(4) … */
const PNG_IHDR_LENGTH = 13;
const PNG_IHDR_TYPE = Buffer.from('IHDR', 'ascii');
const GIF_SIGNATURES = [
Buffer.from('GIF87a', 'ascii'),
Buffer.from('GIF89a', 'ascii'),
];
/** GIF 논리 화면 가로·세로 (little-endian 16비트) 위치 */
const GIF_WIDTH_OFFSET = 6;
const GIF_HEIGHT_OFFSET = 8;
const JPEG_SOI = [0xff, 0xd8, 0xff];
const JPEG_MARKER_PREFIX = 0xff;
/** 길이 없이 홀로 오는 표지 — TEM, RST0~7 */
const JPEG_STANDALONE = new Set([
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
]);
/** 그림 자료가 시작되거나 끝나는 표지 — 그 전에 SOF 가 있어야 한다 */
const JPEG_SOS = 0xda;
const JPEG_EOI = 0xd9;
/** 프레임 머리(SOF0~15) 중 프레임이 아닌 것 — DHT(C4)·JPG(C8)·DAC(CC) */
const JPEG_NOT_SOF = new Set([0xc4, 0xc8, 0xcc]);
const JPEG_SOF_FIRST = 0xc0;
const JPEG_SOF_LAST = 0xcf;
function startsWith(data: Buffer, prefix: Buffer | readonly number[]): boolean {
if (data.length < prefix.length) return false;
for (let index = 0; index < prefix.length; index++) {
if (data[index] !== prefix[index]) return false;
}
return true;
}
function sized(
type: SignatureImageType,
width: number,
height: number,
): SignatureImageInfo | null {
return width > 0 && height > 0 ? { type, width, height } : null;
}
function readPng(data: Buffer): SignatureImageInfo | null {
// 서명 8 + 길이 4 + 형식 4 + 가로 4 + 세로 4
if (data.length < 24) return null;
if (data.readUInt32BE(8) !== PNG_IHDR_LENGTH) return null;
if (!data.subarray(12, 16).equals(PNG_IHDR_TYPE)) return null;
return sized('image/png', data.readUInt32BE(16), data.readUInt32BE(20));
}
function readGif(data: Buffer): SignatureImageInfo | null {
if (data.length < GIF_HEIGHT_OFFSET + 2) return null;
return sized(
'image/gif',
data.readUInt16LE(GIF_WIDTH_OFFSET),
data.readUInt16LE(GIF_HEIGHT_OFFSET),
);
}
/**
* JPEG 은 표지(0xFF xx)와 길이로 이어진 조각을 따라가 첫 프레임 머리(SOFn)에서 크기를 읽는다.
* 조각 길이를 따라 앞으로만 가므로 입력 길이에 비례해 끝난다. SOF 전에 그림 자료(SOS)·끝(EOI)이 오면 받지 않는다.
*/
function readJpeg(data: Buffer): SignatureImageInfo | null {
let index = 2;
while (index < data.length) {
if (data[index] !== JPEG_MARKER_PREFIX) return null;
// 표지 앞의 채움 바이트(0xFF 여러 개)는 건너뛴다
while (index < data.length && data[index] === JPEG_MARKER_PREFIX) index++;
if (index >= data.length) return null;
const marker = data[index];
index++;
if (JPEG_STANDALONE.has(marker)) continue;
if (marker === JPEG_SOS || marker === JPEG_EOI) return null;
if (index + 2 > data.length) return null;
const length = data.readUInt16BE(index);
if (length < 2 || index + length > data.length) return null;
const isFrame =
marker >= JPEG_SOF_FIRST &&
marker <= JPEG_SOF_LAST &&
!JPEG_NOT_SOF.has(marker);
if (isFrame) {
// 길이(2) 정밀도(1) 세로(2) 가로(2)
if (length < 7) return null;
return sized(
'image/jpeg',
data.readUInt16BE(index + 5),
data.readUInt16BE(index + 3),
);
}
index += length;
}
return null;
}
/** 내용으로 형식과 크기를 알아낸다 — PNG·JPEG·GIF 가 아니거나 머리가 망가졌으면 null */
export function detectSignatureImage(data: Buffer): SignatureImageInfo | null {
if (startsWith(data, PNG_SIGNATURE)) return readPng(data);
if (GIF_SIGNATURES.some((signature) => startsWith(data, signature))) {
return readGif(data);
}
if (startsWith(data, JPEG_SOI)) return readJpeg(data);
return null;
}
@@ -0,0 +1,23 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
/** 서명 그림 id — 서버가 만든 UUID */
const SIGNATURE_IMAGE_ID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
*/
@Injectable()
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
return value.toLowerCase();
}
throw new BusinessException('RES_001', {
context: { reason: 'malformed-signature-image-id' },
});
}
}
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { SignatureImageStorage } from './signature-image-storage';
const NAME = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.png';
describe('SignatureImageStorage (서명 그림 파일)', () => {
let root: string;
let dir: string;
let storage: SignatureImageStorage;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'signature-storage-'));
// 아직 없는 하위 폴더 — 처음 쓸 때 만든다
dir = join(root, 'mail-signatures');
storage = new SignatureImageStorage(dir);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('쓰고 읽고 지운다 — 없는 파일은 null, 두 번 지워도 된다', async () => {
await expect(storage.read(NAME)).resolves.toBeNull();
await expect(storage.list()).resolves.toEqual([]);
await storage.write(NAME, Buffer.from('png'));
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('png'));
expect((await storage.list()).map((file) => file.fileName)).toEqual([NAME]);
await storage.remove(NAME);
await storage.remove(NAME);
await expect(storage.read(NAME)).resolves.toBeNull();
});
it('같은 이름을 덮어쓰지 않는다', async () => {
await storage.write(NAME, Buffer.from('first'));
await expect(storage.write(NAME, Buffer.from('second'))).rejects.toThrow();
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('first'));
});
it('서버가 만든 이름(UUID.png·jpg·gif)이 아니면 경로로 쓰지 않는다', async () => {
for (const name of [
'../../etc/passwd',
`../${NAME}`,
`${NAME}/x`,
NAME.toUpperCase(),
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.svg',
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.jpeg',
'',
]) {
await expect(storage.write(name, Buffer.from('x'))).rejects.toThrow(
'서명 그림 파일 이름 형식이 올바르지 않습니다.',
);
await expect(storage.read(name)).rejects.toThrow();
await expect(storage.remove(name)).rejects.toThrow();
}
});
it('그룹은 쓸 수 없고 다른 사람은 읽을 수도 없다 (POSIX — Windows 는 권한 비트가 없다)', async () => {
await storage.write(NAME, Buffer.from('png'));
if (process.platform !== 'win32') {
// 만들 때 0o640 — umask 가 더 좁히는 것은 괜찮다
const mode = statSync(join(dir, NAME)).mode;
expect(mode & 0o027).toBe(0);
}
});
});
@@ -0,0 +1,89 @@
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { SIGNATURE_IMAGE_DIR } from './mail-signature.constants';
/** 서버가 만든 파일 이름만 다룬다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
const STORED_FILE_NAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|gif)$/;
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) */
const FILE_MODE = 0o640;
export interface StoredFile {
readonly fileName: string;
readonly modifiedAt: Date;
}
function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
}
/**
* 서명 그림 파일 저장소 — 업로드 볼륨의 하위 폴더(SIGNATURE_IMAGE_DIR).
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식을 정해 준다 (.claude/rules/security.md "파일 업로드").
* 모듈은 useFactory 로 만든다 (생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
*/
export class SignatureImageStorage {
constructor(private readonly dir: string = SIGNATURE_IMAGE_DIR) {}
async write(fileName: string, data: Buffer): Promise<void> {
await mkdir(this.dir, { recursive: true });
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
await writeFile(this.pathOf(fileName), data, {
flag: 'wx',
mode: FILE_MODE,
});
}
/** 없으면 null */
async read(fileName: string): Promise<Buffer | null> {
try {
return await readFile(this.pathOf(fileName));
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
}
/** 지운다 — 이미 없으면 그대로 둔다 */
async remove(fileName: string): Promise<void> {
try {
await unlink(this.pathOf(fileName));
} catch (error) {
if (!isMissing(error)) throw error;
}
}
/** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */
async list(): Promise<StoredFile[]> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
if (isMissing(error)) return [];
throw error;
}
const files: StoredFile[] = [];
for (const fileName of names.filter((name) =>
STORED_FILE_NAME.test(name),
)) {
try {
const { mtime } = await stat(join(this.dir, fileName));
files.push({ fileName, modifiedAt: mtime });
} catch (error) {
if (!isMissing(error)) throw error;
}
}
return files;
}
private pathOf(fileName: string): string {
if (!STORED_FILE_NAME.test(fileName)) {
// 서버가 만든 이름이 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
throw new Error('서명 그림 파일 이름 형식이 올바르지 않습니다.');
}
return join(this.dir, fileName);
}
}
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Put,
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 { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
import { DraftDto, DraftSavedDto } from './dto/mail-response.dto';
import { DraftBodyDto } from './dto/send-message.dto';
import { MailIdPipe } from './mail-id.pipe';
import { DRAFTS_ROUTE } from './mail.constants';
import { MailService } from './mail.service';
/** 저장 요청의 400 실패 — 발송 본문과 같은 한도 */
const SAVE_BAD_REQUEST =
`MAIL_002 받는 사람 합계 100명 초과 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음 · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · ` +
'VAL_001 형식(제목 255자 초과·draftId 같은 모르는 필드 포함) · FILE_001 첨부 형식 (모두 400)';
// 임시 저장 (계약 2절 "임시 저장") — 목록·버리기·되돌리기는 메일 API(GET·DELETE·PATCH /api/messages) 그대로다.
// 저장 본문은 발송 본문과 같은 필드·한도이고 JSON 본문 4MB 까지 받는다 (app.setup.ts — 앞단 nginx 도 같은 값이어야 한다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@Controller(DRAFTS_ROUTE)
export class DraftController {
constructor(private readonly mail: MailService) {}
@Get(':id')
@ApiOperation({
summary: '이어 쓸 임시 저장본',
description:
'서식 본문은 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값이다 — 다른 메일 프로그램이 만든 초안도 같다. ' +
'임시보관함의 초안이 아니면(발송 중에 생긴 내부 초안 포함) 404.',
})
@ApiStandardResponse(DraftDto, { description: '임시 저장본' })
@ApiErrorResponse('RES_001', '없는 메일 · 임시보관함의 초안이 아님')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
getDraft(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.mail.getDraft(user, id);
}
@Post()
@ApiOperation({
summary: '임시 저장 — 처음 저장',
description:
'발송 본문과 같은 필드·한도(받는 사람 0명도 된다, draftId 없음). 발송 중복 방지용 Message-ID·지문 헤더는 넣지 않는다. ' +
'본문이 너무 길면 400 MAIL_007.',
})
@ApiStandardResponse(DraftSavedDto, {
status: HttpStatus.CREATED,
description: '저장한 임시 저장본 — 다음 저장은 PUT /api/drafts/{id}',
})
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
@ApiErrorResponse(
'MAIL_007',
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도 (텍스트 100,000자·서식 500,000자·정화 전 비용·정화 뒤 2,000,000바이트)',
)
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
createDraft(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: DraftBodyDto,
) {
return this.mail.createDraft(user, dto);
}
@Put(':id')
@ApiOperation({
summary: '임시 저장 — 바꿔 저장 (전체 교체, 새 id)',
description:
'메일은 고칠 수 없어 새 초안을 만든 뒤 옛것을 지운다 — 응답의 id·attachments 로 바꿔 쓴다. ' +
'{id} 가 임시보관함의 초안이 아니면 아무것도 지우지 않고 404 RES_001, ' +
'옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 저장하지 않고 409 MAIL_008 — 클라이언트는 POST 로 새로 남긴다.',
})
@ApiStandardResponse(DraftSavedDto, {
description: '새 임시 저장본 (새 id)',
})
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
@ApiErrorResponse(
'MAIL_007',
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도',
)
@ApiErrorResponse('RES_001', '임시보관함의 초안이 아님 (지우지 않았다)')
@ApiErrorResponse(
'MAIL_008',
'다른 곳에서 먼저 바뀌었거나 지워짐 — 저장하지 않았다 (POST 로 새로 남긴다)',
)
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
replaceDraft(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Body() dto: DraftBodyDto,
) {
return this.mail.replaceDraft(user, id, dto);
}
}
@@ -3,13 +3,17 @@ import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
import type {
Draft,
MailAddress,
MailAttachment,
Mailbox,
MailboxRole,
MailQuota,
MessageAttachment,
MessageDetail,
MessageSummary,
RecipientSuggestion,
SavedDraft,
} from '@/modules/mail/mail.types';
/**
@@ -63,6 +67,20 @@ export class MailAttachmentDto implements MailAttachment {
size!: number;
}
/** 메일 상세의 첨부 — 업로드 응답·임시 저장본의 첨부(MailAttachmentDto)와 달리 isInline 이 있다 */
export class MessageAttachmentDto
extends MailAttachmentDto
implements MessageAttachment
{
@ApiProperty({
description:
'이 부분을 돌려준 htmlBody 안에 그림으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다 (전달할 때는 그대로 모두 붙인다). ' +
'너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우·텍스트 메일은 false',
example: false,
})
isInline!: boolean;
}
export class MailboxDto implements Mailbox {
@ApiProperty({ description: '메일함 id', example: 'a' })
id!: string;
@@ -187,7 +205,12 @@ export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '중요 표시 여부', example: false })
isFlagged!: boolean;
@ApiProperty({ description: '첨부 여부', example: true })
@ApiProperty({
description:
'첨부 여부 — 본문 그림(본문에 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB)이 아닌 첨부가 하나라도 있으면 true. ' +
'서명 로고처럼 본문 그림만 있으면 false',
example: true,
})
hasAttachment!: boolean;
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
@@ -268,10 +291,11 @@ export class MessageDetailDto
isBodyTruncated!: boolean;
@ApiProperty({
description: '첨부 (본문 내장 이미지 포함)',
type: [MailAttachmentDto],
description:
'첨부 (본문 내장 이미지 포함) — 본문에 그림으로 넣은 부분은 isInline: true',
type: [MessageAttachmentDto],
})
attachments!: MailAttachmentDto[];
attachments!: MessageAttachmentDto[];
}
export class MessagePageDto {
@@ -289,3 +313,126 @@ export class SentMessageDto {
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
id!: string;
}
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
export class DraftDto implements Draft {
@ApiProperty({
description:
'임시 저장본 id — 저장할 때마다 바뀐다 (응답의 id 로 바꿔 쓴다)',
example: 'faaaaabi',
})
id!: string;
@ApiProperty({
description: '받는 사람 (0명도 된다)',
type: [MailAddressDto],
})
to!: MailAddressDto[];
@ApiProperty({ description: '참조', type: [MailAddressDto] })
cc!: MailAddressDto[];
@ApiProperty({ description: '숨은 참조', type: [MailAddressDto] })
bcc!: MailAddressDto[];
@ApiProperty({
description: '제목 (없으면 빈 문자열)',
example: 'Re: 메일 서버 이전 일정 공유',
})
subject!: string;
@ApiProperty({
description: '텍스트 대체 본문 (없으면 빈 문자열)',
example: '도윤님, 확인했습니다.',
})
body!: string;
@ApiProperty({
description:
'서식 본문 — 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값. 없거나 비면 null. ' +
'다른 메일 프로그램이 만든 초안도 같은 정화를 거친다 (정화 한도를 넘는 HTML 이면 null)',
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description: '이 판의 첨부 — id 가 저장할 때마다 바뀐다',
type: [MailAttachmentDto],
})
attachments!: MailAttachmentDto[];
@ApiProperty({
description: '답장할 메일 id — 없거나 원 메일이 사라졌으면 null',
nullable: true,
type: String,
example: 'eaaaaab',
})
inReplyTo!: string | null;
@ApiProperty({
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
example: '2026-10-27T06:12:00.000Z',
})
updatedAt!: string;
}
/** 계약 2절 "임시 저장"의 DraftSaved */
export class DraftSavedDto implements SavedDraft {
@ApiProperty({ description: '새 임시 저장본 id', example: 'faaaaabj' })
id!: string;
@ApiProperty({
description: '새 판의 첨부 — 클라이언트는 이 id 로 바꿔 쓴다',
type: [MailAttachmentDto],
})
attachments!: MailAttachmentDto[];
@ApiProperty({
description: '저장한 시각 (ISO 8601 UTC)',
example: '2026-10-27T06:12:00.000Z',
})
updatedAt!: string;
}
const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [
'directory',
'recent',
];
/** 계약 2절 "받는 사람 추천" */
export class RecipientSuggestionDto implements RecipientSuggestion {
@ApiProperty({
description: '표시 이름 (없으면 null)',
nullable: true,
type: String,
example: '이도윤',
})
name!: string | null;
@ApiProperty({
description: '메일 주소',
example: 'doyun.lee@arasystem.it.kr',
})
email!: string;
@ApiProperty({
description:
'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory',
enum: SUGGESTION_SOURCES,
example: 'directory',
})
source!: RecipientSuggestion['source'];
}
export class RecipientSuggestionPageDto {
@ApiProperty({
type: [RecipientSuggestionDto],
description:
'사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순',
})
items!: RecipientSuggestionDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함 (걸러진 개수)' })
pageInfo!: PageInfoDto;
}
@@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants';
/** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */
export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`,
example: '도윤',
})
@IsOptional()
@IsString({ message: '찾는 글자 형식이 올바르지 않습니다.' })
@MaxLength(MAX_SUGGESTION_QUERY_LENGTH, {
message: `찾는 글자는 ${MAX_SUGGESTION_QUERY_LENGTH}자 이하로 입력해 주세요.`,
})
q?: string;
}
@@ -76,21 +76,12 @@ export class OutgoingAttachmentDto {
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[];
/**
* 발송 본문과 임시 저장 본문이 함께 쓰는 필드 — 계약 2절 "발송 본문"·"임시 저장" (같은 필드·같은 한도).
* 받는 사람(to)은 둘의 규칙이 달라(발송 1명 이상 · 임시 저장 0명도 됨) 아래 두 클래스가 따로 둔다 —
* class-validator 는 부모의 검사를 자식에서 풀 수 없다.
*/
export class ComposeContentDto {
@ApiPropertyOptional({ description: '참조', type: [RecipientDto] })
@IsOptional()
@IsArray({ message: '참조 형식이 올바르지 않습니다.' })
@@ -172,3 +163,44 @@ export class SendMessageDto {
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
inReplyTo?: string;
}
/** POST /api/messages — 계약 2절 "발송 본문" */
export class SendMessageDto extends ComposeContentDto {
@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:
'이어 쓴 임시 저장본 id — 보내기에 성공하면(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 서버가 그 초안을 지운다. ' +
'지우지 못하면 로그만 남긴다. 내용 지문에는 넣지 않는다 (계약 2절 "임시 저장")',
example: 'faaaaabi',
})
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' })
draftId?: string;
}
/** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */
export class DraftBodyDto extends ComposeContentDto {
@ApiProperty({
description: `받는 사람 (0명도 된다 — 받는 사람·참조·숨은 참조 합계 ${MAX_RECIPIENTS}명 이하)`,
type: [RecipientDto],
})
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
@ArrayMaxSize(MAX_RECIPIENTS, {
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
})
@ValidateNested({ each: true })
@Type(() => RecipientDto)
to!: RecipientDto[];
}
@@ -3,6 +3,8 @@ import {
INLINE_IMAGES_MAX_COUNT,
INLINE_IMAGE_MAX_BYTES,
findReferencedContentIds,
hasRealAttachment,
isBodyImagePart,
normalizeContentId,
selectInlineImages,
toDataUri,
@@ -10,11 +12,72 @@ import {
const part = (cid: string, type = 'image/png', size = 1000): InlinePart => ({
attachmentId: `blob-${cid}`,
attachmentIndex: 0,
cid,
type,
size,
});
/** 본문 그림 판정 (첨부 표시) — 계약 2절 MessageSummary.hasAttachment */
describe('본문 그림 · 첨부 표시', () => {
/** 우리가 보내는 서명 그림 부분 — disposition inline + Content-ID, 300KB 이하 */
const signature = {
type: 'image/png',
disposition: 'inline',
cid: 'sig-3f1c0a2e@arasystem.it.kr',
size: 18_342,
};
it('본문 그림 — 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB', () => {
expect(isBodyImagePart(signature)).toBe(true);
// disposition 이 없어도(Outlook 등) · 형식 대소문자·별칭(image/jpg) · 꺾쇠 붙은 Content-ID · 정확히 300KB
expect(isBodyImagePart({ ...signature, disposition: null })).toBe(true);
expect(
isBodyImagePart({
type: 'IMAGE/JPG',
disposition: undefined,
cid: '<image001.png@01DA>',
size: INLINE_IMAGE_MAX_BYTES,
}),
).toBe(true);
for (const notBodyImage of [
{ ...signature, disposition: 'attachment' },
{ ...signature, disposition: ' Attachment ' },
{ ...signature, cid: null },
{ ...signature, cid: ' <> ' },
{ ...signature, type: 'image/svg+xml' },
{ ...signature, type: 'application/pdf' },
{ ...signature, type: null },
{ ...signature, size: INLINE_IMAGE_MAX_BYTES + 1 },
{ ...signature, size: 0 },
{ ...signature, size: undefined },
]) {
expect(isBodyImagePart(notBodyImage)).toBe(false);
}
});
it('첨부 표시 — 본문 그림이 아닌 부분이 하나라도 있으면 true, 본문 그림만 있거나 부분이 없으면 false', () => {
const pdf = {
type: 'application/pdf',
disposition: 'attachment',
cid: null,
size: 20_480,
};
expect(hasRealAttachment([])).toBe(false);
expect(hasRealAttachment([signature])).toBe(false);
expect(
hasRealAttachment([signature, { ...signature, cid: 'logo2@x' }]),
).toBe(false);
expect(hasRealAttachment([signature, pdf])).toBe(true);
// 첨부로 붙인 그림(disposition attachment)은 그림이어도 첨부다
expect(
hasRealAttachment([{ ...signature, disposition: 'attachment' }]),
).toBe(true);
});
});
describe('본문 내장 이미지 정책', () => {
it('Content-ID 를 꺾쇠·퍼센트 인코딩·대소문자와 무관하게 맞춘다', () => {
expect(normalizeContentId('<Logo@AraSystem.it.kr>')).toBe(
@@ -55,6 +55,39 @@ export function inlineImageType(type: string): string | null {
return INLINE_IMAGE_TYPES.get(type.trim().toLowerCase()) ?? null;
}
/** 첨부 부분의 모양 — 본문 그림인지 가릴 때 보는 것만 (엔진과 무관한 모양) */
export interface AttachmentPartInfo {
readonly type?: string | null;
readonly disposition?: string | null;
readonly cid?: string | null;
readonly size?: number | null;
}
/**
* 본문 그림인가 — 메일을 열면 본문 안에 그려질 수 있는 부분이라 첨부 표시(hasAttachment)에서 뺀다.
* 모두 맞아야 한다: 넣을 수 있는 그림 형식(inlineImageType) · Content-ID 가 있음 · disposition 이 `attachment` 가 아님(inline 이거나 없음) ·
* 크기 1바이트 이상 INLINE_IMAGE_MAX_BYTES 이하 (selectInlineImages 가 넣을 후보로 고르는 기준과 같다).
* 본문이 실제로 가리키는지는 보지 않는다 — 목록은 본문을 읽지 않는다.
* 우리가 보내는 서명 그림(disposition inline + cid, 300KB 이하)은 늘 본문 그림이다.
*/
export function isBodyImagePart(part: AttachmentPartInfo): boolean {
const size = part.size ?? 0;
return (
inlineImageType(part.type ?? '') !== null &&
normalizeContentId(part.cid ?? '') !== '' &&
(part.disposition ?? '').trim().toLowerCase() !== 'attachment' &&
size > 0 &&
size <= INLINE_IMAGE_MAX_BYTES
);
}
/** 첨부가 있는 메일인가 — 본문 그림이 아닌 부분이 하나라도 있으면 true (본문 그림만 있으면 첨부 없는 메일) */
export function hasRealAttachment(
parts: readonly AttachmentPartInfo[],
): boolean {
return parts.some((part) => !isBodyImagePart(part));
}
/**
* 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한.
* (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다)
@@ -106,7 +106,11 @@ describe('sanitizeMailHtml', () => {
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
);
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
expect(result).toEqual({
html: '<b>y</b>',
hasBlockedImages: false,
embeddedContentIds: new Set(),
});
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
expect(cpuMs()).toBeLessThan(2_000);
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
@@ -190,6 +194,28 @@ describe('sanitizeMailHtml', () => {
expect(result.html).toBe('<p>a</p>');
expect(result.hasBlockedImages).toBe(false);
expect(result.embeddedContentIds).toEqual(new Set());
});
it('결과에 그림으로 실제로 넣은 Content-ID 만 알려 준다 — 버린 영역 안·img 가 아닌 곳의 참조는 들지 않는다 (첨부의 isInline)', () => {
const inline = new Map([
['logo@x', PNG_DATA_URI],
['hidden@x', PNG_DATA_URI],
['background@x', PNG_DATA_URI],
['unused@x', PNG_DATA_URI],
]);
const result = sanitizeMailHtml(
'<p><img src="CID:Logo@X"><img src="cid:logo@x"></p>' +
'<svg><img src="cid:hidden@x"></svg>' +
'<div style="background:url(cid:background@x)">배경</div>',
inline,
);
expect(result.embeddedContentIds).toEqual(new Set(['logo@x']));
expect(result.html).toBe(
`<p><img src="${PNG_DATA_URI}" /><img src="${PNG_DATA_URI}" /></p><div>배경</div>`,
);
});
it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => {
@@ -31,6 +31,11 @@ export interface SanitizedMailHtml {
readonly html: string;
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
readonly hasBlockedImages: boolean;
/**
* 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값).
* 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다
*/
readonly embeddedContentIds: ReadonlySet<string>;
}
const ALLOWED_TAGS = [
@@ -294,6 +299,7 @@ function resolveImage(
attribs: sanitizeHtml.Attributes,
inlineImages: ReadonlyMap<string, string>,
markBlocked: () => void,
markEmbedded: (contentId: string) => void,
): sanitizeHtml.Attributes {
const next: sanitizeHtml.Attributes = { ...attribs };
delete next.src;
@@ -305,8 +311,13 @@ function resolveImage(
const src = attribs.src?.trim() ?? '';
if (/^cid:/i.test(src)) {
const dataUri = inlineImages.get(normalizeContentId(src.slice(4)));
if (dataUri) next.src = dataUri;
const contentId = normalizeContentId(src.slice(4));
const dataUri = inlineImages.get(contentId);
if (dataUri) {
next.src = dataUri;
// 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다
markEmbedded(contentId);
}
return next;
}
if (SAFE_DATA_IMAGE.test(src)) {
@@ -338,6 +349,10 @@ export function sanitizeMailHtml(
const markBlocked = () => {
hasBlockedImages = true;
};
const embeddedContentIds = new Set<string>();
const markEmbedded = (contentId: string) => {
embeddedContentIds.add(contentId);
};
const output = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
@@ -352,7 +367,12 @@ export function sanitizeMailHtml(
transformTags: {
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
img: (tagName, attribs) => {
const next = resolveImage(attribs, inlineImages, markBlocked);
const next = resolveImage(
attribs,
inlineImages,
markBlocked,
markEmbedded,
);
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
@@ -370,5 +390,5 @@ export function sanitizeMailHtml(
},
});
return { html: output, hasBlockedImages };
return { html: output, hasBlockedImages, embeddedContentIds };
}
@@ -506,6 +506,199 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
});
});
/** 계약 2절 "서식 본문"의 그림 예외 — 보내는 사람 자신의 서명 그림만 남는다 */
describe('서명 그림', () => {
const MINE = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const NOT_MINE = '00000000-0000-4000-8000-000000000000';
const options = { signatureImageIds: new Set([MINE]) };
const src = (id: string) => `/api/mail-signature/images/${id}`;
it('내 서명 그림은 src·width·alt 만 남긴다 (style·그 밖의 속성은 지운다)', () => {
expect(
clean(
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" style="border: 1px solid #000" height="50" class="x" onerror="x()">`,
options,
),
).toBe(
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" />`,
);
});
it('주소의 대소문자·앞뒤 공백은 정규화한다 — 지문이 같은 그림에 같은 값이 되게', () => {
expect(
clean(
`<img src=" /api/mail-signature/images/${MINE.toUpperCase()} " width=" 96 ">`,
options,
),
).toBe(`<img src="${src(MINE)}" width="96" />`);
});
it('width 는 1~600 정수만, alt 는 200자까지 — 형식이 틀리면 그 속성만 뺀다', () => {
for (const width of [
'0',
'601',
'96.5',
'96px',
'50%',
'096',
'-1',
'',
]) {
expect(
clean(`<img src="${src(MINE)}" width="${width}">`, options),
).toBe(`<img src="${src(MINE)}" />`);
}
expect(clean(`<img src="${src(MINE)}" width="600">`, options)).toBe(
`<img src="${src(MINE)}" width="600" />`,
);
expect(
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}">`, options),
).toBe(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}" />`);
expect(
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(201)}">`, options),
).toBe(`<img src="${src(MINE)}" />`);
});
it('남의 그림·찾지 못한 그림·서명 그림이 아닌 주소는 태그째 지운다', () => {
for (const img of [
`<img src="${src(NOT_MINE)}" width="96">`,
`<img src="https://portal.example${src(MINE)}">`,
`<img src="${src(MINE)}?v=2">`,
`<img src="/api/files/${MINE}">`,
'<img src="cid:logo@x">',
'<img src="data:image/png;base64,iVBORw0KGgo=">',
'<img src="https://evil.example/pixel.gif">',
'<img>',
]) {
expect(clean(`<p>앞${img}뒤</p>`, options)).toBe('<p>앞뒤</p>');
}
});
it('그림 id 를 주지 않으면 서명 그림도 지운다 (기본값 — 그림 없음)', () => {
expect(clean(`<p>앞<img src="${src(MINE)}">뒤</p>`)).toBe('<p>앞뒤</p>');
});
it('지운 그림 뒤의 형제 태그는 제 이름으로 닫힌다', () => {
expect(
clean(
`<p><img src="${src(NOT_MINE)}"><strong>굵게</strong><img src="${src(MINE)}"><em>기울임</em></p>`,
options,
),
).toBe(
`<p><strong>굵게</strong><img src="${src(MINE)}" /><em>기울임</em></p>`,
);
});
it('한 번 더 정화해도 그대로이고, 받은 메일 정화를 거쳐도 그림이 남는다 (임시 저장본을 다시 읽을 때)', () => {
const once = clean(
`<div><p>서명</p><img src="${src(MINE)}" width="144" alt=""></div>`,
options,
);
expect(clean(once, options)).toBe(once);
// 받은 메일 정화는 서명 그림 주소(상대 주소)를 외부 이미지로 보지 않고 지운다 — 우리 주소는 cid 로 실려 나간다
expect(sanitizeMailHtml(once)).toEqual({
html: '<div><p>서명</p></div>',
hasBlockedImages: false,
embeddedContentIds: new Set(),
});
});
/**
* 그림(img) 속성 표본 — frontend/src/test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES 와 **같은 표**다 (행과 순서까지 그대로).
* [속성, 값, 서버가 남기는가] — src 가 남지 않으면 그림째 지우고, width · alt 가 남지 않으면 그 속성만 지운다.
* 표의 id 는 보내는 사람의 그림이다 (서버는 여기에 더해 주인을 본다 — 위 테스트들).
* ⚠️ 그림 규칙을 바꾸면 두 표(이 표·outgoing-style-samples.ts)와 두 규칙 파일(sanitize-outgoing-html.ts·프론트 signature-image.ts)을 함께 고친다.
*/
describe('그림 속성 표본 — 프론트 편집기와 같은 표', () => {
const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const IMAGE_SAMPLES: readonly (readonly [
attribute: 'src' | 'width' | 'alt',
value: string,
kept: boolean,
])[] = [
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
[
'src',
`/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`,
true,
],
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
['src', '', false],
['src', '/api/mail-signature/images/', false],
['src', '/api/mail-signature/images/not-a-uuid', false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
[
'src',
`https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
false,
],
[
'src',
`//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
false,
],
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
['src', 'https://evil.example/t.png', false],
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
['width', '1', true],
['width', '96', true],
['width', '144', true],
['width', '600', true],
['width', ' 144 ', true],
['width', '0', false],
['width', '601', false],
['width', '1000', false],
['width', '096', false],
['width', '-1', false],
['width', '+5', false],
['width', '14.5', false],
['width', '144px', false],
['width', '50%', false],
['width', '1e2', false],
['width', '', false],
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
['alt', '', true],
['alt', '회사 로고', true],
['alt', 'ㄱ'.repeat(200), true],
['alt', 'ㄱ'.repeat(201), false],
];
/** 서버가 다시 쓰는 정식 주소 (소문자 경로 · 소문자 id) */
const canonical = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`;
const mine = { signatureImageIds: new Set([SAMPLE_IMAGE_ID]) };
it.each(IMAGE_SAMPLES)(
'%s="%s" — 서버가 남기는가 %s',
(attribute, value, kept) => {
if (attribute === 'src') {
// 주소가 남지 않으면 그림째 지운다
expect(clean(`<p>앞<img src="${value}">뒤</p>`, mine)).toBe(
kept ? `<p>앞<img src="${canonical}" />뒤</p>` : '<p>앞뒤</p>',
);
return;
}
// 폭·설명은 그 속성만 — 남으면 폭은 앞뒤 공백을 뗀 값, 설명은 그대로
const left = attribute === 'width' ? value.trim() : value;
expect(
clean(`<img src="${canonical}" ${attribute}="${value}">`, mine),
).toBe(
kept
? `<img src="${canonical}" ${attribute}="${left}" />`
: `<img src="${canonical}" />`,
);
},
);
});
});
/**
* 실제 편집기가 보내는 모양 — 브라우저가 style 을 정규화해 쓴다(값 끝의 `;`·쉼표 뒤 공백·따옴표 없는 한글 글꼴 이름).
* 보내는 정화와 받은 메일 정화를 모두 지나도 남아야 한다.
@@ -1,6 +1,7 @@
import sanitizeHtml from 'sanitize-html';
import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
import { parseSignatureImageId, signatureImagePath } from './signature-images';
/**
* 보내는 메일의 서식 본문 정화 (순수 함수) — 계약 2절 "서식 본문 (bodyHtml)".
@@ -17,7 +18,10 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
* 그렇게 숨긴 글이 보이지 않는 채로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
* (받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 모양은 그대로 둔다)
* - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다
* - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로)
* - 그림은 **보내는 사람 자신의 서명 그림만** — `<img src="/api/mail-signature/images/{id}">` 이고 그 id 가
* `signatureImageIds`(그 사람의 그림)에 있을 때. 속성은 src·width(1~600 정수)·alt(200자 이하)만 남기고, 그 밖의 img 는 태그째 지운다
* (html/signature-images.ts — 보낼 때 서비스가 주소를 `cid:` 로 바꿔 메일 안에 함께 싣는다)
* - 폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (붙여 넣은 그림은 첨부로 — 서명 그림만 예외)
*
* 같은 입력이면 늘 같은 결과다 — 발송 중복 방지의 내용 지문이 이 결과를 쓴다 (send-idempotency.ts).
* 우리 웹메일이 다시 읽을 때는 받은 메일 정화를 한 번 더 거친다 — 여기서 남긴 서식은 그쪽 목록에도 모두 있다
@@ -64,6 +68,8 @@ const ALLOWED_TAGS = [
'tr',
'th',
'td',
// 서명 그림만 — 아래 keepSignatureImage 가 그 밖의 img 를 태그째 지운다
'img',
];
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
@@ -72,8 +78,21 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
ol: ['start'],
td: ['colspan', 'rowspan'],
th: ['colspan', 'rowspan'],
img: ['src', 'width', 'alt'],
};
/** 서명 그림의 표시 폭 상한 (px) — 계약 2절 "서명" (작게 96 · 보통 144 · 원본은 원래 폭, 600 이하) */
export const MAX_SIGNATURE_IMAGE_WIDTH = 600;
/** 서명 그림의 대체 글자 상한 (JS 문자열 길이) */
export const MAX_SIGNATURE_IMAGE_ALT_LENGTH = 200;
/** width 값 — 앞자리 0 없는 1~3자리 정수 (600 이하는 따로 본다) */
const IMAGE_WIDTH = /^[1-9]\d{0,2}$/;
export interface OutgoingSanitizeOptions {
/** 남겨도 되는 서명 그림 id — 보내는 사람 자신의 그림(소문자 UUID). 없으면 그림을 하나도 남기지 않는다 */
readonly signatureImageIds?: ReadonlySet<string>;
}
// ── style 값 형식 ───────────────────────────────────────────────────────────
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다
@@ -235,11 +254,49 @@ function keepSafeLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
: { tagName: 'span', attribs: {} };
}
/**
* 서명 그림만 남긴다 — src 가 정확히 `/api/mail-signature/images/{id}` 이고 그 id 가 보내는 사람의 그림일 때.
* 속성은 src(정규화한 주소)·width(1~600 정수)·alt(200자 이하)만 — style 도 남기지 않는다. 형식이 틀린 width·alt 는 그 속성만 뺀다.
* 그 밖의 img 는 태그째 지운다 — 이름을 '' 로 바꾸면 허용 목록에 없어 버려진다(img 는 빈 요소라 버릴 내용도 없다).
* '' 인 이유는 받은 메일 정화(sanitize-mail-html.ts 의 img)와 같다 — 버린 태그의 바뀐 이름이 다음 형제를 닫는 데 쓰이지 않게.
*/
function keepSignatureImage(
attribs: sanitizeHtml.Attributes,
allowedIds: ReadonlySet<string>,
): sanitizeHtml.Tag {
const id = parseSignatureImageId(attribs.src);
if (id === null || !allowedIds.has(id)) return { tagName: '', attribs: {} };
const next: sanitizeHtml.Attributes = { src: signatureImagePath(id) };
const width = attribs.width?.trim();
if (
width !== undefined &&
IMAGE_WIDTH.test(width) &&
Number(width) <= MAX_SIGNATURE_IMAGE_WIDTH
) {
next.width = width;
}
if (
attribs.alt !== undefined &&
attribs.alt.length <= MAX_SIGNATURE_IMAGE_ALT_LENGTH
) {
next.alt = attribs.alt;
}
return { tagName: 'img', attribs: next };
}
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
/**
* 서식 본문을 정화한다. 결과가 비면(서식 없는 빈 입력·모두 지워진 경우) 빈 문자열이다 —
* 그때 서비스는 텍스트 메일로 보낸다.
* 같은 입력·같은 `signatureImageIds` 면 늘 같은 결과다 (발송 지문).
*/
export function sanitizeOutgoingHtml(html: string): string {
export function sanitizeOutgoingHtml(
html: string,
options: OutgoingSanitizeOptions = {},
): string {
const signatureImageIds = options.signatureImageIds ?? NO_SIGNATURE_IMAGES;
return sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: ALLOWED_ATTRIBUTES,
@@ -263,6 +320,8 @@ export function sanitizeOutgoingHtml(html: string): string {
tagName,
attribs: keepNumbers(attribs, ['colspan', 'rowspan']),
}),
img: (_tagName, attribs) =>
keepSignatureImage(attribs, signatureImageIds),
},
}).trim();
}
@@ -0,0 +1,59 @@
import {
findSignatureImageIds,
parseSignatureImageId,
rewriteSignatureImages,
signatureImagePath,
} from './signature-images';
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const OTHER = '00000000-0000-4000-8000-000000000000';
describe('서명 그림 주소 (signature-images)', () => {
it('주소가 정확히 /api/mail-signature/images/{uuid} 일 때만 id 를 준다 (대소문자 무시, 소문자로)', () => {
expect(parseSignatureImageId(signatureImagePath(ID))).toBe(ID);
expect(
parseSignatureImageId(
` /api/mail-signature/images/${ID.toUpperCase()} `,
),
).toBe(ID);
for (const src of [
undefined,
'',
`https://portal.example/api/mail-signature/images/${ID}`,
`//portal.example/api/mail-signature/images/${ID}`,
`/api/mail-signature/images/${ID}?x=1`,
`/api/mail-signature/images/${ID}#a`,
`/api/mail-signature/images/${ID}/../x`,
`/api/mail-signature/images/not-a-uuid`,
`/api/files/${ID}`,
`cid:${ID}`,
]) {
expect(parseSignatureImageId(src)).toBeNull();
}
});
it('정화한 HTML 이 가리키는 그림 id 를 나온 순서대로, 겹치지 않게 찾는다', () => {
const html =
`<p>a</p><img src="${signatureImagePath(OTHER)}" width="96" />` +
`<img src="${signatureImagePath(ID)}" alt="" />` +
`<img src="${signatureImagePath(OTHER)}" />`;
expect(findSignatureImageIds(html)).toEqual([OTHER, ID]);
expect(findSignatureImageIds('<p>그림 없음</p>')).toEqual([]);
});
it('주소를 바꾸고(cid 등) 나머지 속성은 그대로 둔다 — 실을 수 없는 그림은 태그째 지운다', () => {
const html =
`<p>서명</p><img src="${signatureImagePath(ID)}" width="144" alt="로고" />` +
`<img src="${signatureImagePath(OTHER)}" />`;
expect(
rewriteSignatureImages(html, (id) =>
id === ID ? `cid:sig-${id}@arasystem.test` : null,
),
).toBe(
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="로고" />`,
);
});
});
@@ -0,0 +1,69 @@
/**
* 서명 그림 주소 규칙 (순수 함수) — 계약 2절 "서식 본문"의 그림 예외 · "서명".
*
* 서식 본문에 남는 그림은 보내는 사람 자신의 서명 그림 `<img src="/api/mail-signature/images/{id}">` 뿐이다.
* - 임시 저장본은 이 주소 그대로 둔다 (편집기가 그 주소로 그림을 다시 불러온다)
* - 보낼 때는 서버가 그림을 메일 안에 함께 실어(`multipart/related`) 주소를 `cid:` 로 바꾼다 — 받는 쪽에서 외부 이미지처럼 막히지 않게
*
* ⚠️ 아래 정규식은 **보내는 정화를 거친 HTML** 에만 쓴다 — 정화가 img 를 늘 `<img src="…" width="…" alt="…" />` 순서로 다시 쓴다
* (sanitize-outgoing-html.ts). 남이 만든 HTML 에 직접 쓰지 않는다.
*/
/** 서명 그림 주소 앞부분 — 컨트롤러 경로(`/api/mail-signature/images/{id}`)와 같다 */
export const SIGNATURE_IMAGE_PATH_PREFIX = '/api/mail-signature/images/';
/** 서명 그림 id — 서버가 만든 UUID (소문자 16진) */
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
/** 그림 주소 전체 — 앞뒤에 다른 글자가 붙으면(쿼리·조각·다른 경로) 서명 그림이 아니다. 대소문자는 가리지 않는다 */
const SIGNATURE_IMAGE_SRC = new RegExp(
`^${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})$`,
'i',
);
/** 정화한 HTML 안의 서명 그림 태그 — 정화가 src 를 맨 앞에 쓴다 */
const SANITIZED_SIGNATURE_IMG = new RegExp(
`<img src="${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})"([^>]*)>`,
'g',
);
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** 그림 id 의 주소 */
export function signatureImagePath(id: string): string {
return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`;
}
/** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */
export function parseSignatureImageId(src: string | undefined): string | null {
const match = src?.trim().match(SIGNATURE_IMAGE_SRC);
return match ? match[1].toLowerCase() : null;
}
/** 정화한 HTML 이 가리키는 서명 그림 id — 나온 순서대로, 겹치지 않게 */
export function findSignatureImageIds(sanitizedHtml: string): string[] {
const ids = new Set<string>();
for (const match of sanitizedHtml.matchAll(SANITIZED_SIGNATURE_IMG)) {
ids.add(match[1]);
}
return [...ids];
}
/**
* 정화한 HTML 의 서명 그림 주소를 바꾼다 — `resolve` 가 돌려준 값(예: `cid:…`)으로, null 이면 그 img 를 지운다
* (실을 수 없는 그림 — 그사이 지운 그림 등). 나머지 속성(width·alt)은 그대로 둔다.
*/
export function rewriteSignatureImages(
sanitizedHtml: string,
resolve: (id: string) => string | null,
): string {
return sanitizedHtml.replace(
SANITIZED_SIGNATURE_IMG,
(_whole, id: string, rest: string) => {
const src = resolve(id);
return src === null ? '' : `<img src="${src}"${rest}>`;
},
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -73,6 +73,11 @@ const REQUEST_TIMEOUT_MS = 10_000;
export const JMAP_SEND_TIMEOUT_MS = 40_000;
/** 첨부 업로드 제한 시간 — 임시 파일에서 엔진으로 흘려보낸다 (내부망 기준 20MB 에 충분한 여유) */
const UPLOAD_TIMEOUT_MS = 120_000;
/**
* 발송 중에 서명 그림(한 장 300KB 이하)을 올리는 제한 시간 — 발송 전체가 클라이언트 한도(60초) 안에 답하도록 짧게.
* 발송 경로는 남은 시간이 이보다 적으면 그만큼만 기다린다 (jmap-mail.provider.ts 의 SEND_TOTAL_BUDGET_MS).
*/
export const JMAP_INLINE_UPLOAD_TIMEOUT_MS = 15_000;
/** 다운로드는 응답 헤더까지만 제한한다 — 본문은 클라이언트로 흘려보내는 중이라 시간을 정할 수 없다 */
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
@@ -432,6 +437,7 @@ export class JmapClient {
user: string,
content: Blob,
type: string,
options: { readonly timeoutMs?: number } = {},
): Promise<{ blobId: string; type: string; size: number }> {
const session = await this.getSession(user);
const path = expandTemplate(session.uploadPath, {
@@ -448,7 +454,7 @@ export class JmapClient {
},
body: content,
},
UPLOAD_TIMEOUT_MS,
options.timeoutMs ?? UPLOAD_TIMEOUT_MS,
'upload',
async (response) => {
await this.assertImpersonationAccepted(response, user);
@@ -79,10 +79,22 @@ describe('JMAP 오류 매핑', () => {
properties: ['attachments/2/blobId'],
}),
).toBe(true);
// 서명 그림을 실을 때(bodyStructure) — Stalwart 는 부분 위치 없이 알려 준다
for (const property of [
'bodyStructure/blobId',
'bodyStructure/subParts/1/blobId',
]) {
expect(
isMissingAttachmentBlob({
type: 'invalidProperties',
properties: [property],
}),
).toBe(true);
}
expect(
isMissingAttachmentBlob({
type: 'invalidProperties',
properties: ['attachments/type', 'to/0/email'],
properties: ['attachments/type', 'to/0/email', 'bodyStructure/type'],
}),
).toBe(false);
expect(isMissingAttachmentBlob({ type: 'overQuota' })).toBe(false);
+8 -2
View File
@@ -31,8 +31,14 @@ 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 참조 속성 — `attachments/blobId`, `attachments/0/blobId`.
* 서명 그림을 함께 실을 때는 구조를 직접 적어(bodyStructure) 첨부도 그 안에 든다 — Stalwart(v0.16)는 그 안의 없는 blob 을
* 부분 위치 없이 `bodyStructure/blobId` 로 알려 준다 (2026-10-27 개발 엔진으로 확인). 이 경로에서 blob 을 찾지 못하는 것은
* 첨부뿐이다 — 서명 그림은 같은 요청 직전에 올린다.
*/
const ATTACHMENT_BLOB_PROPERTY =
/^(?:attachments(?:\/\d+)?|bodyStructure(?:\/subParts\/\d+)*)\/blobId$/;
/**
* 첨부 blob 을 엔진에서 찾지 못한 오류인지 — MAIL_004 로 응답한다.
+43 -23
View File
@@ -1,4 +1,5 @@
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
import type {
InlinePart,
MailAddress,
@@ -23,7 +24,11 @@ import type {
* 엔진 응답은 외부 입력이므로 모든 필드를 방어적으로 읽는다.
*/
/** 목록·요약에 필요한 Email 속성 */
/**
* 목록·요약에 필요한 Email 속성.
* 엔진의 hasAttachment 대신 첨부 부분(attachments)을 읽어 우리가 정한다 — 본문 그림(서명 로고 등)만 있는 메일은 첨부 없음
* (html/inline-images.ts 의 hasRealAttachment). 부분에서는 SUMMARY_BODY_PART_PROPERTIES 만 읽는다.
*/
export const SUMMARY_PROPERTIES = [
'id',
'threadId',
@@ -34,23 +39,33 @@ export const SUMMARY_PROPERTIES = [
'subject',
'preview',
'receivedAt',
'hasAttachment',
'attachments',
'size',
];
/** 상세에 필요한 Email 속성 */
/** 목록·요약에서 첨부 부분마다 읽는 속성 — 첨부 표시(본문 그림인지) 판정에 쓰는 것만 */
export const SUMMARY_BODY_PART_PROPERTIES = [
'type',
'disposition',
'cid',
'size',
];
/** 상세에 필요한 Email 속성 (겹치는 이름은 한 번만 — attachments) */
export const DETAIL_PROPERTIES = [
...SUMMARY_PROPERTIES,
'cc',
'bcc',
'replyTo',
'sentAt',
'messageId',
'references',
'bodyValues',
'htmlBody',
'textBody',
'attachments',
...new Set([
...SUMMARY_PROPERTIES,
'cc',
'bcc',
'replyTo',
'sentAt',
'messageId',
'references',
'bodyValues',
'htmlBody',
'textBody',
'attachments',
]),
];
/** 본문 부분(part)에서 읽는 속성 */
@@ -166,7 +181,7 @@ export function toSummary(email: JmapEmail): MessageSummary {
receivedAt: toUtcIso(email.receivedAt) ?? new Date(0).toISOString(),
isRead: email.keywords?.$seen === true,
isFlagged: email.keywords?.$flagged === true,
hasAttachment: email.hasAttachment === true,
hasAttachment: hasRealAttachment(email.attachments ?? []),
size: email.size ?? 0,
};
}
@@ -233,14 +248,19 @@ function joinBodyValues(
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,
}));
const inlineParts: InlinePart[] = attachments.flatMap((part, index) =>
typeof part.cid === 'string' && part.cid.trim() !== ''
? [
{
attachmentId: part.blobId,
attachmentIndex: index,
cid: part.cid.trim(),
type: partType(part),
size: part.size ?? 0,
},
]
: [],
);
const html = joinBodyValues(email, email.htmlBody, 'text/html');
const text = joinBodyValues(email, email.textBody, 'text/plain');
@@ -102,11 +102,17 @@ export interface JmapIdentity {
}
export interface JmapGetResult<T> {
/** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */
state?: string;
list?: T[];
notFound?: string[];
}
export interface JmapSetResult {
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
oldState?: string | null;
/** 이 요청이 바꾼 뒤의 상태 */
newState?: string;
created?: Record<string, { id?: string }>;
updated?: Record<string, unknown>;
destroyed?: string[];
@@ -36,9 +36,21 @@ export const MAX_BODY_VALUE_BYTES = 2_000_000;
*/
export const MESSAGES_ROUTE = 'messages';
/**
* 임시 저장 라우트 경로 (`/api/drafts`) — 컨트롤러와, 저장 요청의 본문 한도를 발송과 같게 늘리는 파서(app.setup.ts)가 같이 쓴다.
*/
export const DRAFTS_ROUTE = 'drafts';
/** 검색어 최대 길이 */
export const MAX_SEARCH_LENGTH = 200;
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
/** 받는 사람 추천 — 주고받은 주소를 모으는 최근 메일 수 (보낸편지함·받은편지함 각각) */
export const SUGGESTION_SENT_SCAN = 500;
export const SUGGESTION_RECEIVED_SCAN = 500;
/** 받는 사람 표시 이름 최대 길이 */
export const MAX_DISPLAY_NAME_LENGTH = 200;
+15
View File
@@ -6,7 +6,10 @@ import {
} from '@nestjs/platform-express';
import { BusinessException } from '@/common/errors/business.exception';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
import { JmapClient } from './jmap/jmap.client';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
@@ -24,6 +27,8 @@ import {
} from './mail.settings';
import { MailboxController, MailQuotaController } from './mailbox.controller';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
function readMailSettings(config: ConfigService): MailSettings {
return buildMailSettings((key) => config.get<string>(key));
@@ -74,6 +79,9 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
*
* 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 —
* 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
*/
@Module({
imports: [
@@ -81,12 +89,18 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
inject: [ConfigService],
useFactory: attachmentUploadOptions,
}),
// 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명")
MailSignatureModule,
// 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천")
MailDirectoryModule,
],
controllers: [
MailboxController,
MailQuotaController,
MessageController,
MailAttachmentController,
DraftController,
RecipientSuggestionController,
],
providers: [
{
@@ -97,6 +111,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
JmapClient,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
RecipientSuggestionService,
MailUploadTempCleanup,
],
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
+35
View File
@@ -1,5 +1,8 @@
import type {
AttachmentContent,
Correspondence,
DraftContent,
DraftSource,
MailAccountProfile,
MailAttachment,
Mailbox,
@@ -10,6 +13,7 @@ import type {
MessageSummary,
MessageUpdate,
OutgoingMessage,
SavedDraft,
SentMessageRecord,
UploadedFile,
} from './mail.types';
@@ -104,4 +108,35 @@ export interface MailProvider {
attachmentId: string,
maxBytes: number,
): Promise<Buffer | null>;
/**
* 임시 저장 (계약 2절 "임시 저장") — 임시보관함에 새 초안을 만든다. `replaceId` 가 있으면 그 초안을 바꾼다:
* - 메일은 고칠 수 없어 새 초안을 **만든 뒤에** 옛것을 지운다 — 만들기가 실패하면 옛것은 그대로다
* - `replaceId` 가 임시보관함의 사용자 초안이 아니면 아무것도 만들지 않고 RES_001
* - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 바꿨거나 지웠음) 방금 만든 것을 지우고 MAIL_008
* 발송 중복 방지용 Message-ID·지문·발송 중 표시는 넣지 않는다. 첨부 합계·없는 첨부는 발송과 같다(FILE_002·MAIL_004).
*/
saveDraft(
user: string,
content: DraftContent,
replaceId?: string,
): Promise<SavedDraft>;
/** 쓰기 화면이 이어 쓸 임시 저장본 — 임시보관함의 사용자 초안이 아니면(발송 중에 생긴 내부 초안 포함) RES_001 */
getDraft(user: string, id: string): Promise<DraftSource>;
/**
* 임시 저장본을 지운다 (보낸 뒤) — 사용자 초안만 지운다. 이미 없거나 초안이 아니면 아무것도 하지 않는다
* (클라이언트가 보낸 id 로 다른 메일을 지우지 않게).
*/
deleteDraft(user: string, id: string): Promise<void>;
/**
* 주고받은 주소 (받는 사람 추천) — 그 직원의 보낸편지함 최근 `sent` 통의 받는 사람·참조·숨은참조와
* 받은편지함 최근 `received` 통의 보낸 사람. 다른 직원의 메일함은 읽지 않는다.
*/
listCorrespondents(
user: string,
limits: { readonly sent: number; readonly received: number },
): Promise<Correspondence[]>;
}
+669 -4
View File
@@ -2,17 +2,20 @@ import { Logger } from '@nestjs/common';
import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import type { SendMessageDto } from './dto/send-message.dto';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
} from './mail.constants';
import { MailService } from './mail.service';
import { DRAFT_CLEANUP_WAIT_MS, MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { MessageDetailSource } from './mail.types';
import type { DraftSource, MessageDetailSource } from './mail.types';
import type { RecipientSuggestionService } from './recipient-suggestions.service';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
@@ -62,9 +65,51 @@ function fakeProvider(): ProviderMocks {
uploadAttachment: mockOf<'uploadAttachment'>(),
openAttachment: mockOf<'openAttachment'>(),
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
saveDraft: mockOf<'saveDraft'>().mockResolvedValue({
id: 'draft-2',
attachments: [],
updatedAt: '2026-10-27T01:00:00.000Z',
}),
getDraft: mockOf<'getDraft'>(),
deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined),
listCorrespondents: mockOf<'listCorrespondents'>(),
};
}
/** 메일 서비스가 쓰는 서명 서비스의 두 메서드 — 그림 id 목록과 그림 읽기 */
interface SignatureMocks {
imageIdsOf: jest.Mock<
ReturnType<MailSignatureService['imageIdsOf']>,
Parameters<MailSignatureService['imageIdsOf']>
>;
loadImages: jest.Mock<
ReturnType<MailSignatureService['loadImages']>,
Parameters<MailSignatureService['loadImages']>
>;
}
function fakeSignatures(): SignatureMocks {
return {
imageIdsOf: jest
.fn<
ReturnType<MailSignatureService['imageIdsOf']>,
Parameters<MailSignatureService['imageIdsOf']>
>()
.mockResolvedValue(new Set()),
loadImages: jest
.fn<
ReturnType<MailSignatureService['loadImages']>,
Parameters<MailSignatureService['loadImages']>
>()
.mockResolvedValue([]),
};
}
/** 내 서명 그림 · 남의 서명 그림 (서명 그림 id 는 UUID) */
const MY_IMAGE_ID = '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f';
const OTHER_IMAGE_ID = '5e6f0b9a-4f9e-4c2d-8d3a-9f4e1a2b3c4d';
const imageSrc = (id: string) => `/api/mail-signature/images/${id}`;
async function codeOf(
run: () => Promise<unknown>,
): Promise<string | undefined> {
@@ -130,15 +175,45 @@ const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
...overrides,
});
const draftDto = (overrides: Partial<DraftBodyDto> = {}): DraftBodyDto => ({
to: [],
subject: '쓰는 중',
body: '본문',
...overrides,
});
const draftSource = (overrides: Partial<DraftSource> = {}): DraftSource => ({
id: 'draft-1',
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
cc: [],
bcc: [],
subject: '쓰는 중',
textBody: '본문',
rawHtmlBody: null,
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-27T01:00:00.000Z',
...overrides,
});
describe('MailService', () => {
let provider: ProviderMocks;
let signatures: SignatureMocks;
let suggestions: { invalidate: jest.Mock<void, [string]> };
let service: MailService;
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
beforeEach(() => {
provider = fakeProvider();
service = new MailService(provider, settings);
signatures = fakeSignatures();
suggestions = { invalidate: jest.fn<void, [string]>() };
service = new MailService(
provider,
settings,
signatures as unknown as MailSignatureService,
suggestions as unknown as RecipientSuggestionService,
);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
@@ -294,18 +369,21 @@ describe('MailService', () => {
inlineParts: [
{
attachmentId: 'blob-logo',
attachmentIndex: 0,
cid: 'logo@x',
type: 'image/png',
size: 3,
},
{
attachmentId: 'blob-big',
attachmentIndex: 1,
cid: 'big@x',
type: 'image/png',
size: 5 * 1024 * 1024,
},
{
attachmentId: 'blob-unused',
attachmentIndex: 2,
cid: 'unused@x',
type: 'image/png',
size: 3,
@@ -330,9 +408,13 @@ describe('MailService', () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody: '<p>본문</p><img src="cid:logo@x">',
attachments: [
{ id: 'blob-logo', name: 'logo.png', type: 'image/png', size: 3 },
],
inlineParts: [
{
attachmentId: 'blob-logo',
attachmentIndex: 0,
cid: 'logo@x',
type: 'image/png',
size: 3,
@@ -344,14 +426,130 @@ describe('MailService', () => {
new BusinessException('MAIL_001'),
);
// 넣지 못한 그림은 첨부 목록에 남는다 (isInline false)
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
htmlBody: '<p>본문</p>',
attachments: [
{
id: 'blob-logo',
name: 'logo.png',
type: 'image/png',
size: 3,
isInline: false,
},
],
});
});
describe('첨부의 isInline — 본문에 그림으로 넣은 부분만', () => {
const logoPart = {
attachmentId: 'blob-logo',
attachmentIndex: 1,
cid: 'logo@x',
type: 'image/png',
size: 3,
};
const attachments = [
{
id: 'blob-xlsx',
name: '체크리스트.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 2048,
},
{
id: 'blob-logo',
name: 'signature-1.png',
type: 'image/png',
size: 3,
},
];
const inlineOf = (detail: { attachments: { isInline: boolean }[] }) =>
detail.attachments.map((attachment) => attachment.isInline);
it('본문에 넣은 그림은 isInline true, 그 밖의 첨부는 false — 응답에는 정화 전 원본·내부 값이 없다', async () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody: '<p>서명</p><img src="cid:logo@x">',
attachments,
inlineParts: [logoPart],
}),
);
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
const detail = await service.getMessage(user, 'm1');
expect(detail.attachments).toEqual([
{ ...attachments[0], isInline: false },
{ ...attachments[1], isInline: true },
]);
expect(detail).not.toHaveProperty('inlineParts');
});
it('넣지 못한 그림은 false — 본문이 가리키지 않음 · 너무 큼 · 버린 영역(svg) 안', async () => {
for (const [rawHtmlBody, size] of [
['<p>그림을 가리키지 않는 본문</p>', 3],
['<img src="cid:logo@x">', 5 * 1024 * 1024],
['<svg><img src="cid:logo@x"></svg><p>본문</p>', 3],
] as const) {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody,
attachments,
inlineParts: [{ ...logoPart, size }],
}),
);
provider.readAttachmentBytes.mockResolvedValue(
Buffer.from([1, 2, 3]),
);
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
false,
false,
]);
}
});
it('HTML 이 없거나(텍스트 메일) 정화 한도를 넘어 비웠으면 모두 false', async () => {
for (const rawHtmlBody of [null, TOO_DEEP_HTML]) {
provider.getMessage.mockResolvedValue(
source({ rawHtmlBody, attachments, inlineParts: [logoPart] }),
);
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
false,
false,
]);
}
expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
});
it('같은 Content-ID 가 둘이면 실제로 넣은 첫 부분만 isInline', async () => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody: '<img src="cid:logo@x">',
attachments: [
attachments[1],
{ ...attachments[1], id: 'blob-copy' },
],
inlineParts: [
{ ...logoPart, attachmentIndex: 0 },
{ ...logoPart, attachmentId: 'blob-copy', attachmentIndex: 1 },
],
}),
);
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
true,
false,
]);
});
});
it('정화 한도를 넘는 HTML(깊은 중첩·이어 붙여 너무 긴 본문)은 정화하지 않고 텍스트 본문만 내려준다 — 내장 이미지도 읽지 않는다', async () => {
const logo = {
attachmentId: 'blob-logo',
attachmentIndex: 0,
cid: 'logo@x',
type: 'image/png',
size: 3,
@@ -1000,6 +1198,473 @@ describe('MailService', () => {
});
});
/** 계약 2절 "서식 본문"의 그림 예외 — 보낼 때 내 서명 그림을 메일 안에 싣는다 */
describe('서명 그림 (발송)', () => {
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const cid = `sig-${MY_IMAGE_ID}@arasystem.test`;
it('내 서명 그림은 cid 로 바꾸고 그림을 함께 실어 보낸다 (width·alt 는 그대로)', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
signatures.loadImages.mockResolvedValue([
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
]);
await service.sendMessage(
user,
sendDto({
bodyHtml: `<p>김아라 드림</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="아라시스템">`,
}),
);
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
MY_IMAGE_ID,
]);
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.htmlBody).toBe(
`<p>김아라 드림</p><img src="cid:${cid}" width="144" alt="아라시스템" />`,
);
expect(outgoing.inlineImages).toEqual([
{ cid, type: 'image/png', name: 'signature-1.png', data: png },
]);
// 텍스트 대체 본문에는 그림이 없다
expect(outgoing.body).toBe('본문');
});
it('같은 그림은 늘 같은 cid 다 — 같은 키의 재시도가 같은 지문이 된다', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
signatures.loadImages.mockResolvedValue([
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
]);
const dto = sendDto({
bodyHtml: `<p>서명</p><img src="${imageSrc(MY_IMAGE_ID)}">`,
});
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
await service.sendMessage(
user,
dto,
'00000000-0000-4000-8000-000000000000',
);
const [[, first], [, second]] = provider.sendMessage.mock.calls;
expect(first.htmlBody).toBe(second.htmlBody);
expect(first.fingerprint).toBe(
payloadFingerprint(first, IDEMPOTENCY_KEY),
);
});
it('남의 그림은 정화가 지우고, 파일을 읽지 못한 내 그림은 태그째 뺀다 — 그림이 없으면 싣지 않는다', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
// 그사이 지운 그림 — 서명 서비스가 빼고 돌려준다
signatures.loadImages.mockResolvedValue([]);
await service.sendMessage(
user,
sendDto({
bodyHtml: `<p>앞<img src="${imageSrc(OTHER_IMAGE_ID)}">가운데<img src="${imageSrc(MY_IMAGE_ID)}">뒤</p>`,
}),
);
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
MY_IMAGE_ID,
]);
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.htmlBody).toBe('<p>앞가운데뒤</p>');
expect(outgoing).not.toHaveProperty('inlineImages');
});
it(`본문의 서명 그림도 서로 다른 내 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (보내지도 저장하지도 않는다)`, async () => {
const ids = [
MY_IMAGE_ID,
'1b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
'2b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
'3b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
];
signatures.imageIdsOf.mockResolvedValue(new Set(ids));
const img = (id: string) => `<img src="${imageSrc(id)}" width="96">`;
const tooMany = `<p>서명</p>${ids.map(img).join('')}`;
for (const run of [
() => service.sendMessage(user, sendDto({ bodyHtml: tooMany })),
() =>
service.sendMessage(
user,
sendDto({ bodyHtml: tooMany }),
IDEMPOTENCY_KEY,
),
() => service.createDraft(user, draftDto({ bodyHtml: tooMany })),
() =>
service.replaceDraft(
user,
'draft-1',
draftDto({ bodyHtml: tooMany }),
),
]) {
expect(await failureOf(run)).toEqual({
code: 'VAL_001',
reason: 'too-many-signature-images',
});
}
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.findSentMessage).not.toHaveBeenCalled();
expect(provider.saveDraft).not.toHaveBeenCalled();
expect(signatures.loadImages).not.toHaveBeenCalled();
// 세 장 + 같은 그림 한 번 더 — 서로 다른 그림은 세 장이다
await expect(
service.createDraft(
user,
draftDto({
bodyHtml: [ids[0], ids[1], ids[2], ids[0]].map(img).join(''),
}),
),
).resolves.toMatchObject({ id: 'draft-2' });
});
it('서명 그림 주소가 없으면 그림 목록을 읽지 않는다 (DB 를 보지 않는다)', async () => {
await service.sendMessage(
user,
sendDto({
bodyHtml:
'<p>그림 없는 메일<img src="https://evil.example/p.gif"></p>',
}),
);
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
expect(signatures.loadImages).not.toHaveBeenCalled();
expect(provider.sendMessage.mock.calls[0][1].htmlBody).toBe(
'<p>그림 없는 메일</p>',
);
});
});
/** 계약 2절 "임시 저장" — 발송과 같은 준비(정화·한도), 받는 사람 0명도 된다 */
describe('임시 저장', () => {
it('받는 사람이 없어도 저장한다 — 보내는 정화를 거친 서식 본문을 넘기고, 중복 방지 Message-ID·지문은 넣지 않는다', async () => {
await expect(
service.createDraft(
user,
draftDto({
subject: '줄\r\n바꿈',
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
attachments: [{ id: 'blob-1', name: '체크리스트.xlsx' }],
}),
),
).resolves.toEqual({
id: 'draft-2',
attachments: [],
updatedAt: '2026-10-27T01:00:00.000Z',
});
expect(provider.saveDraft).toHaveBeenCalledTimes(1);
const [who, content, replaceId] = provider.saveDraft.mock.calls[0];
expect(who).toBe(user.email);
expect(replaceId).toBeUndefined();
// toEqual 은 정의된 다른 속성(messageId·fingerprint·inlineImages)이 있으면 실패한다
expect(content).toEqual({
to: [],
cc: [],
bcc: [],
subject: '줄 바꿈',
body: '본문',
htmlBody: '<p>쓰는 중</p>',
attachments: [
{
id: 'blob-1',
name: '체크리스트.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
],
inReplyTo: undefined,
});
expect(provider.findSentMessage).not.toHaveBeenCalled();
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('바꿔 저장은 옛 id 를 함께 넘기고, 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)을 그대로 돌려준다', async () => {
await service.replaceDraft(user, 'draft-1', draftDto());
expect(provider.saveDraft.mock.calls[0][2]).toBe('draft-1');
for (const code of ['RES_001', 'MAIL_008'] as const) {
provider.saveDraft.mockRejectedValueOnce(new BusinessException(code));
expect(
await codeOf(() => service.replaceDraft(user, 'draft-1', draftDto())),
).toBe(code);
}
});
it('발송과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · 첨부 개수 MAIL_006 · 첨부 형식 FILE_001 (저장하지 않는다)', async () => {
const recipients = (count: number) =>
Array.from({ length: count }, (_, index) => ({
name: null,
email: `r${index}@arasystem.test`,
}));
const cases: [Partial<DraftBodyDto>, string, string][] = [
[
{ body: 'x'.repeat(MAX_BODY_LENGTH + 1) },
'MAIL_007',
'body-too-long',
],
[
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
'MAIL_007',
'body-html-too-long',
],
[{ bodyHtml: TOO_DEEP_HTML }, 'MAIL_007', 'body-html-too-complex'],
[{ bodyHtml: '&'.repeat(400_001) }, 'MAIL_007', 'body-html-too-large'],
[
{ to: recipients(60), cc: recipients(41) },
'MAIL_002',
'too-many-recipients',
],
[
{
attachments: Array.from({ length: 21 }, (_, index) => ({
id: `blob-${index}`,
name: `보고서${index}.pdf`,
})),
},
'MAIL_006',
'too-many-attachments',
],
[
{ attachments: [{ id: 'blob-1', name: '설치.exe' }] },
'FILE_001',
'attachment-type-not-allowed',
],
];
for (const [overrides, code, reason] of cases) {
expect(
await failureOf(() => service.createDraft(user, draftDto(overrides))),
).toEqual({ code, reason });
expect(
await failureOf(() =>
service.replaceDraft(user, 'draft-1', draftDto(overrides)),
),
).toEqual({ code, reason });
}
expect(provider.saveDraft).not.toHaveBeenCalled();
});
it('서명 그림은 내 그림만 그림 주소 그대로 남긴다 — 저장본에는 그림을 싣지 않는다', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
await service.createDraft(
user,
draftDto({
bodyHtml: `<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt=""><img src="${imageSrc(OTHER_IMAGE_ID)}" width="144">`,
}),
);
expect(signatures.imageIdsOf).toHaveBeenCalledWith(user.email);
expect(signatures.loadImages).not.toHaveBeenCalled();
expect(provider.saveDraft.mock.calls[0][1].htmlBody).toBe(
`<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="" />`,
);
});
describe('이어 쓰기', () => {
it('엔진의 초안을 계약 모양으로 — 서식 본문은 보내는 정화를 거친다 (다른 메일 프로그램이 만든 초안도)', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
provider.getDraft.mockResolvedValue(
draftSource({
rawHtmlBody: `<html><body><h1 style="color: red">제목</h1><p>본문<img src="https://tracker.example/p.gif"></p><img src="${imageSrc(MY_IMAGE_ID)}" width="96"><img src="${imageSrc(OTHER_IMAGE_ID)}"></body></html>`,
inReplyTo: 'eaaaaab',
}),
);
await expect(service.getDraft(user, 'draft-1')).resolves.toEqual({
id: 'draft-1',
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
cc: [],
bcc: [],
subject: '쓰는 중',
body: '본문',
// 모르는 태그(h1)는 태그만 지우고 글자는 남긴다 — 외부 이미지·남의 그림은 태그째
bodyHtml: `제목<p>본문</p><img src="${imageSrc(MY_IMAGE_ID)}" width="96" />`,
attachments: [],
inReplyTo: 'eaaaaab',
updatedAt: '2026-10-27T01:00:00.000Z',
});
expect(provider.getDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('텍스트 본문이 없으면 빈 문자열, 서식 본문이 없거나 정화해서 비면 null', async () => {
for (const rawHtmlBody of [null, '', '<script>x()</script>']) {
provider.getDraft.mockResolvedValue(
draftSource({ textBody: null, rawHtmlBody }),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.body).toBe('');
expect(draft.bodyHtml).toBeNull();
}
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
});
it('정화 한도를 넘는 서식 본문은 정화하지 않고 null — 텍스트 본문으로 이어 쓴다 (본문은 로그에 없다)', async () => {
provider.getDraft.mockResolvedValue(
draftSource({ textBody: '비밀 본문', rawHtmlBody: TOO_DEEP_HTML }),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.bodyHtml).toBeNull();
expect(draft.body).toBe('비밀 본문');
const warned = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(warned).toContain('임시 저장본 HTML 이 정화 한도를 넘어');
expect(warned).not.toContain('비밀');
});
it('초안이 아니면 엔진의 RES_001 을 그대로 돌려준다', async () => {
provider.getDraft.mockRejectedValue(new BusinessException('RES_001'));
expect(await codeOf(() => service.getDraft(user, 'm1'))).toBe(
'RES_001',
);
});
});
});
/** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */
describe('보낸 뒤', () => {
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
await expect(
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
).resolves.toEqual({ id: 'sent-1' });
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
// 지우기는 보낸 뒤다
expect(provider.sendMessage.mock.invocationCallOrder[0]).toBeLessThan(
provider.deleteDraft.mock.invocationCallOrder[0],
);
});
it('draftId 가 없으면 지우지 않는다 — 추천은 다시 모은다', async () => {
await service.sendMessage(user, sendDto());
expect(provider.deleteDraft).not.toHaveBeenCalled();
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
});
it('같은 키 재시도로 처음 결과를 돌려줄 때도 초안을 지운다', async () => {
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint: fingerprint ?? null,
});
await expect(
service.sendMessage(
user,
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1' });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('draftId 는 지문에 넣지 않는다 — 초안 id 만 다른 재시도는 같은 내용이다', async () => {
await service.sendMessage(
user,
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
);
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const [[, withDraft], [, withoutDraft]] = provider.sendMessage.mock.calls;
expect(withDraft).not.toHaveProperty('draftId');
expect(withDraft.fingerprint).toBe(withoutDraft.fingerprint);
});
it('초안을 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (주소·제목 없이)', async () => {
provider.deleteDraft.mockRejectedValue(
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
);
await expect(
service.sendMessage(
user,
sendDto({ subject: '비밀 제목', draftId: 'draft-1' }),
),
).resolves.toEqual({ id: 'sent-1' });
const warned = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(warned).toContain('[MAIL_001]');
expect(warned).toContain('보낸 메일의 임시 저장본을 지우지 못했습니다');
expect(warned).toContain('user=a***@arasystem.test');
expect(warned).not.toMatch(/비밀|doyun|ara\.kim/);
});
it('보내지 못하면 초안을 지우지 않고 추천도 그대로다', async () => {
provider.sendMessage.mockRejectedValue(new BusinessException('MAIL_004'));
expect(
await codeOf(() =>
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
),
).toBe('MAIL_004');
expect(provider.deleteDraft).not.toHaveBeenCalled();
expect(suggestions.invalidate).not.toHaveBeenCalled();
});
it(`초안 지우기는 ${DRAFT_CLEANUP_WAIT_MS}ms 까지만 기다린다 — 넘으면 먼저 답하고, 뒤에서 끝난 결과는 로그로만 남긴다`, async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
let finish!: () => void;
provider.deleteDraft.mockImplementation(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const sending = service.sendMessage(
user,
sendDto({ draftId: 'draft-1' }),
);
// 보내고 지우기를 시작할 때까지
await new Promise((resolve) => setImmediate(resolve));
jest.advanceTimersByTime(DRAFT_CLEANUP_WAIT_MS);
await expect(sending).resolves.toEqual({ id: 'sent-1' });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'보낸 메일의 임시 저장본 지우기가 5000ms 안에 끝나지 않아 먼저 답합니다',
),
);
// 뒤에서 끝났다 — 결과는 로그로만
jest.advanceTimersByTime(1_000);
finish();
await new Promise((resolve) => setImmediate(resolve));
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('보낸 메일의 임시 저장본을 늦게 지웠습니다'),
);
} finally {
jest.useRealTimers();
}
});
it('초안 지우기가 제때 끝나면 기다렸다가 답한다 — 경고 없음', async () => {
await service.sendMessage(user, sendDto({ draftId: 'draft-1' }));
expect(provider.deleteDraft).toHaveBeenCalledTimes(1);
const warned = warnSpy.mock.calls.map(([message]) => String(message));
expect(warned.join('\n')).not.toContain('먼저 답합니다');
});
});
it('업로드는 정리한 이름과 확장자로 정한 형식을 엔진에 넘긴다', async () => {
provider.uploadAttachment.mockResolvedValue({
id: 'blob-1',
+327 -34
View File
@@ -6,15 +6,24 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { settledWithin } from '@/shared/utils/settle';
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 {
ComposeContentDto,
DraftBodyDto,
RecipientDto,
SendMessageDto,
} from './dto/send-message.dto';
import type { UpdateMessageDto } from './dto/update-message.dto';
import {
INLINE_IMAGES_TOTAL_MAX_BYTES,
@@ -27,6 +36,11 @@ import {
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import {
SIGNATURE_IMAGE_PATH_PREFIX,
findSignatureImageIds,
rewriteSignatureImages,
} from './html/signature-images';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
@@ -37,17 +51,24 @@ import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
AttachmentContent,
Draft,
DraftContent,
DraftSource,
InlinePart,
MailAddress,
MailAttachment,
Mailbox,
MailQuota,
MessageAttachment,
MessageDetail,
MessageSummary,
OutgoingAttachment,
OutgoingInlineImage,
OutgoingMessage,
SavedDraft,
} from './mail.types';
import { presentMailboxes } from './mailbox.presenter';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import {
idempotentMessageId,
parseIdempotencyKey,
@@ -61,6 +82,12 @@ import {
*/
const UNCERTAIN_SEND_HOLD_MS = 30_000;
/**
* 보낸 뒤 이어 쓴 임시 저장본(draftId)을 지우는 것을 기다리는 최대 시간 — 보통은 끝날 때까지 기다려
* 보낸 직후의 임시보관함 목록에서 그 초안이 사라져 있게 한다. 넘으면 먼저 답하고 지우기는 뒤에서 계속한다 (결과는 로그로만).
*/
export const DRAFT_CLEANUP_WAIT_MS = 5_000;
/** 헤더에 들어가는 값(제목·표시 이름)의 줄바꿈을 공백으로 — 헤더 분리 시도를 막는다 */
function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, ' ').trim();
@@ -71,28 +98,42 @@ function toAddress(recipient: RecipientDto): MailAddress {
return { name: name || null, email: recipient.email.trim() };
}
/**
* 발송 본문의 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화 → 정화 뒤 바이트.
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
/** 준비를 어디에 쓰나 — 보낼 때만 서명 그림을 메일 안에 싣는다 (임시 저장본은 그림 주소 그대로) */
type ComposePurpose = 'send' | 'draft';
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
/** 본문에 그림으로 넣은 첨부가 없다 (HTML 없이 텍스트 본문을 보여 줄 때) */
const NO_INLINE_ATTACHMENTS: ReadonlySet<number> = new Set();
/** 본문에 넣으려고 읽은 내장 이미지 — 정규화한 Content-ID 로 찾는다 */
interface LoadedInlineImages {
/** Content-ID → data: URI (정화가 cid: 를 이것으로 바꾼다) */
readonly dataUris: Map<string, string>;
/** Content-ID → 그 그림을 가져온 첨부의 위치 (attachments 의 몇 번째) */
readonly attachmentIndexes: Map<string, number>;
}
/*
* 본문 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화(서명 그림 포함) → (보낼 때) 그림을 cid 로 → 정화 뒤 바이트.
* 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트가 보내기 전에 세는 방법과 같다
* (class-validator 의 MaxLength 는 서로게이트 쌍·이모지 표시 선택자를 다르게 세서 클라이언트와 어긋났다).
* @returns 정화한 서식 본문 — 서식 본문이 없거나 정화해서 비면 빈 문자열(텍스트 메일)
*/
function prepareBody(body: string, bodyHtml: string | undefined): string {
function assertBodyLength(body: string): void {
if (body.length > MAX_BODY_LENGTH) {
throw new BusinessException('MAIL_007', {
context: { reason: 'body-too-long', length: body.length },
});
}
return bodyHtml ? sanitizeBodyHtml(bodyHtml) : '';
}
/**
* 서식 본문 정화 — 앞뒤로 한도를 본다 (넘으면 MAIL_007).
* - 정화 전: 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
* - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts)
*/
function sanitizeBodyHtml(bodyHtml: string): string {
/** 정화 전 — 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts) */
function assertHtmlBeforeSanitize(bodyHtml: string): void {
if (bodyHtml.length > MAX_BODY_HTML_LENGTH) {
throw new BusinessException('MAIL_007', {
context: { reason: 'body-html-too-long', length: bodyHtml.length },
@@ -109,20 +150,28 @@ function sanitizeBodyHtml(bodyHtml: string): string {
},
});
}
}
const html = sanitizeOutgoingHtml(bodyHtml);
/** 정화 뒤 — 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts) */
function assertHtmlBytes(html: string): void {
const bytes = Buffer.byteLength(html, 'utf8');
if (bytes > MAX_BODY_VALUE_BYTES) {
throw new BusinessException('MAIL_007', {
context: { reason: 'body-html-too-large', bytes },
});
}
return html;
}
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
return `sig-${imageId}@${domain}`;
}
/**
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식, 서명 그림)은 여기서 지킨다.
* 발송과 임시 저장은 같은 준비(prepareContent)를 거친다 — 같은 필드·같은 한도 (계약 2절 "임시 저장").
*/
@Injectable()
export class MailService {
@@ -137,6 +186,8 @@ export class MailService {
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
private readonly signatures: MailSignatureService,
private readonly suggestions: RecipientSuggestionService,
) {}
async listMailboxes(
@@ -195,6 +246,7 @@ export class MailService {
* 그때 isHtmlBodyOmitted 를 켜고, 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
* 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다).
*/
async getMessage(
user: AuthenticatedUser,
@@ -205,12 +257,21 @@ export class MailService {
isRawHtmlBodyTruncated,
isTextBodyTruncated,
inlineParts,
attachments,
...detail
} = await this.provider.getMessage(user.email, id);
/** 첨부 — 본문에 그림으로 넣은 위치만 isInline */
const markInline = (inlined: ReadonlySet<number>): MessageAttachment[] =>
attachments.map((attachment, index) => ({
...attachment,
isInline: inlined.has(index),
}));
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
...detail,
attachments: markInline(NO_INLINE_ATTACHMENTS),
htmlBody: null,
hasBlockedImages: false,
isHtmlBodyOmitted,
@@ -234,10 +295,17 @@ export class MailService {
rawHtmlBody,
inlineParts,
);
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris);
const inlined = new Set(
[...sanitized.embeddedContentIds].flatMap((contentId) => {
const index = inlineImages.attachmentIndexes.get(contentId);
return index === undefined ? [] : [index];
}),
);
const showsHtml = sanitized.html.trim() !== '';
return {
...detail,
attachments: markInline(inlined),
htmlBody: sanitized.html,
hasBlockedImages: sanitized.hasBlockedImages,
isHtmlBodyOmitted: false,
@@ -277,6 +345,9 @@ export class MailService {
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
*
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
*/
async sendMessage(
user: AuthenticatedUser,
@@ -290,9 +361,52 @@ export class MailService {
});
}
const message = this.prepareOutgoing(dto);
if (parsed.key === null) return this.deliver(user, message);
return this.deliverOnce(user, message, parsed.key);
const message = await this.prepareOutgoing(user, dto);
const result =
parsed.key === null
? await this.deliver(user, message)
: await this.deliverOnce(user, message, parsed.key);
await this.afterSent(user, dto.draftId);
return result;
}
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
async createDraft(
user: AuthenticatedUser,
dto: DraftBodyDto,
): Promise<SavedDraft> {
const { content } = await this.prepareContent(user, dto, 'draft');
return this.provider.saveDraft(user.email, content);
}
/** 임시 저장 — 바꿔 저장. 새 id 로 돌려준다 (초안이 아니면 RES_001, 다른 곳에서 먼저 바뀌었으면 MAIL_008) */
async replaceDraft(
user: AuthenticatedUser,
id: string,
dto: DraftBodyDto,
): Promise<SavedDraft> {
const { content } = await this.prepareContent(user, dto, 'draft');
return this.provider.saveDraft(user.email, content, id);
}
/**
* 이어 쓸 임시 저장본 — 서식 본문은 보내는 정화(서명 그림 포함)를 거쳐 내보낸다.
* 다른 메일 프로그램이 만든 초안도 같다 — 정화 한도(길이·깊은 중첩)를 넘는 HTML 이면 bodyHtml 은 null (텍스트 본문으로 이어 쓴다).
*/
async getDraft(user: AuthenticatedUser, id: string): Promise<Draft> {
const source = await this.provider.getDraft(user.email, id);
return {
id: source.id,
to: source.to,
cc: source.cc,
bcc: source.bcc,
subject: source.subject,
body: source.textBody ?? '',
bodyHtml: await this.draftHtml(user, source),
attachments: source.attachments,
inReplyTo: source.inReplyTo,
updatedAt: source.updatedAt,
};
}
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
@@ -358,8 +472,34 @@ export class MailService {
}
}
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 본문 한도(MAIL_007)·서식 본문 정화 */
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
/** 보낼 메일 — 준비에 서명 그림(cid)을 더한다 */
private async prepareOutgoing(
user: AuthenticatedUser,
dto: SendMessageDto,
): Promise<OutgoingMessage> {
const { content, inlineImages } = await this.prepareContent(
user,
dto,
'send',
);
return {
...content,
...(inlineImages.length > 0 ? { inlineImages } : {}),
};
}
/**
* 엔진과 무관한 발송·임시 저장 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈,
* 본문 한도(MAIL_007)·서식 본문 정화(서명 그림 포함)
*/
private async prepareContent(
user: AuthenticatedUser,
dto: ComposeInput,
purpose: ComposePurpose,
): Promise<{
content: DraftContent;
inlineImages: OutgoingInlineImage[];
}> {
const to = dto.to.map(toAddress);
const cc = (dto.cc ?? []).map(toAddress);
const bcc = (dto.bcc ?? []).map(toAddress);
@@ -392,20 +532,168 @@ export class MailService {
});
// 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
const htmlBody = prepareBody(dto.body, dto.bodyHtml);
const { htmlBody, inlineImages } = await this.prepareBody(
user,
dto.body,
dto.bodyHtml,
purpose,
);
return {
to,
cc,
bcc,
subject: singleLine(dto.subject),
body: dto.body,
...(htmlBody ? { htmlBody } : {}),
attachments,
inReplyTo: dto.inReplyTo,
content: {
to,
cc,
bcc,
subject: singleLine(dto.subject),
body: dto.body,
...(htmlBody ? { htmlBody } : {}),
attachments,
inReplyTo: dto.inReplyTo,
},
inlineImages,
};
}
/**
* 본문 한도와 서식 본문 정화 — 순서는 위 "본문 한도" 주석 그대로. 정화한 뒤 서명 그림이 서로 다른 3장을 넘으면 VAL_001.
* 서명 그림은 보내는 사람 자신의 그림만 남는다 — 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다.
* @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일)과 메일에 함께 실을 그림(보낼 때만)
*/
private async prepareBody(
user: AuthenticatedUser,
body: string,
bodyHtml: string | undefined,
purpose: ComposePurpose,
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
assertBodyLength(body);
if (!bodyHtml) return { htmlBody: '', inlineImages: [] };
assertHtmlBeforeSanitize(bodyHtml);
const sanitized = sanitizeOutgoingHtml(bodyHtml, {
signatureImageIds: await this.signatureImageIdsFor(user, bodyHtml),
});
// 서명 그림은 서명 저장과 같은 한도 — 서로 다른 내 그림 3장까지 (정화가 남의 그림은 이미 지웠다). 보내기 전이라 메일은 나가지 않는다
const imageCount = findSignatureImageIds(sanitized).length;
if (imageCount > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: { reason: 'too-many-signature-images', count: imageCount },
});
}
const prepared =
purpose === 'send'
? await this.embedSignatureImages(user, sanitized)
: { htmlBody: sanitized, inlineImages: [] };
assertHtmlBytes(prepared.htmlBody);
return prepared;
}
/** 서명 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다 (없으면 DB 를 보지 않는다) */
private async signatureImageIdsFor(
user: AuthenticatedUser,
html: string,
): Promise<ReadonlySet<string>> {
return html.toLowerCase().includes(SIGNATURE_IMAGE_PATH_PREFIX)
? this.signatures.imageIdsOf(user.email)
: NO_SIGNATURE_IMAGES;
}
/**
* 보낼 때 — 서명 그림 주소를 `cid:` 로 바꾸고 그림을 메일 안에 함께 싣게 한다 (계약 2절 "서식 본문"의 그림 예외).
* 실을 수 없는 그림(그사이 지운 그림 등)은 태그째 지운다.
*/
private async embedSignatureImages(
user: AuthenticatedUser,
html: string,
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
const ids = findSignatureImageIds(html);
if (ids.length === 0) return { htmlBody: html, inlineImages: [] };
const images = await this.signatures.loadImages(user.email, ids);
const cids = new Map(
images.map((image) => [
image.id,
signatureImageCid(image.id, user.email),
]),
);
const inlineImages = images.map(
(image, index): OutgoingInlineImage => ({
cid: cids.get(image.id) ?? '',
type: image.type,
name: `signature-${index + 1}.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}`,
data: image.data,
}),
);
const htmlBody = rewriteSignatureImages(html, (id) => {
const cid = cids.get(id);
return cid ? `cid:${cid}` : null;
}).trim();
return { htmlBody, inlineImages };
}
/** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */
private async draftHtml(
user: AuthenticatedUser,
source: DraftSource,
): Promise<string | null> {
const raw = source.rawHtmlBody;
if (!raw) return null;
const budget = checkHtmlParseBudget(raw);
if (!budget.ok) {
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
this.logger.warn(
`${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`,
);
return null;
}
const html = sanitizeOutgoingHtml(raw, {
signatureImageIds: await this.signatureImageIdsFor(user, raw),
});
return html === '' ? null : html;
}
/**
* 보낸 뒤 — 받는 사람 추천을 다시 모으게 하고, 이어 쓴 임시 저장본을 지운다.
* 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (초안이 하나 남을 뿐이다).
* 지우기는 DRAFT_CLEANUP_WAIT_MS 까지만 기다린다 — 엔진이 늦으면 먼저 답하고 뒤에서 계속한다 (발송 응답을 붙잡지 않게).
*/
private async afterSent(
user: AuthenticatedUser,
draftId: string | undefined,
): Promise<void> {
this.suggestions.invalidate(user.email);
if (!draftId) return;
const startedAt = Date.now();
const cleanup = this.deleteSentDraft(user, draftId, startedAt);
if (!(await settledWithin(cleanup, DRAFT_CLEANUP_WAIT_MS))) {
this.logger.warn(
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본 지우기가 ${DRAFT_CLEANUP_WAIT_MS}ms 안에 끝나지 않아 먼저 답합니다 — 뒤에서 계속합니다: user=${maskEmail(user.email)}, id=${draftId}`,
);
}
}
/** 보낸 메일의 임시 저장본을 지운다 — 던지지 않는다 (발송은 이미 성공이다). 기다림을 넘겨 끝났으면 그 결과를 로그로 */
private async deleteSentDraft(
user: AuthenticatedUser,
draftId: string,
startedAt: number,
): Promise<void> {
try {
await this.provider.deleteDraft(user.email, draftId);
const elapsedMs = Date.now() - startedAt;
if (elapsedMs > DRAFT_CLEANUP_WAIT_MS) {
this.logger.log(
`${traceTag()} 보낸 메일의 임시 저장본을 늦게 지웠습니다 (${elapsedMs}ms): user=${maskEmail(user.email)}, id=${draftId}`,
);
}
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본을 지우지 못했습니다: user=${maskEmail(user.email)}, id=${draftId}, reason=${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async deliver(
user: AuthenticatedUser,
message: OutgoingMessage,
@@ -458,8 +746,11 @@ export class MailService {
user: string,
html: string,
parts: readonly InlinePart[],
): Promise<Map<string, string>> {
const images = new Map<string, string>();
): Promise<LoadedInlineImages> {
const images: LoadedInlineImages = {
dataUris: new Map(),
attachmentIndexes: new Map(),
};
const candidates = selectInlineImages(
parts,
findReferencedContentIds(html),
@@ -496,7 +787,9 @@ export class MailService {
if (!dataUri) continue;
total += data.length;
images.set(normalizeContentId(part.cid), dataUri);
const contentId = normalizeContentId(part.cid);
images.dataUris.set(contentId, dataUri);
images.attachmentIndexes.set(contentId, part.attachmentIndex);
}
return images;
}
+108 -1
View File
@@ -55,6 +55,16 @@ export interface MailAttachment {
readonly size: number;
}
/** 메일 상세의 첨부 — 본문 안에 그림으로 넣었는지 함께 (업로드 응답·임시 저장본의 첨부는 MailAttachment 그대로) */
export interface MessageAttachment extends MailAttachment {
/**
* 이 부분을 돌려준 htmlBody 안에 그림(data: URI)으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다.
* 너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우(isHtmlBodyOmitted)·텍스트 메일은 false.
* 전달할 때는 isInline 이어도 모두 붙인다 (원문 그림을 잃지 않게)
*/
readonly isInline: boolean;
}
export interface MessageDetail extends MessageSummary {
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
@@ -75,12 +85,14 @@ export interface MessageDetail extends MessageSummary {
* 보여 줄 본문 = htmlBody, 그것이 null 이거나 공백뿐이면 textBody (웹 클라이언트의 buildMailDocument 와 같은 규칙)
*/
readonly isBodyTruncated: boolean;
readonly attachments: MailAttachment[];
readonly attachments: MessageAttachment[];
}
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
export interface InlinePart {
readonly attachmentId: string;
/** 상세의 attachments 에서 몇 번째 부분인지 — 본문에 넣었으면 그 첨부의 isInline 을 켠다 */
readonly attachmentIndex: number;
/** Content-ID (꺾쇠 괄호 없이) */
readonly cid: string;
readonly type: string;
@@ -99,7 +111,10 @@ export interface MessageDetailSource extends Omit<
| 'hasBlockedImages'
| 'isHtmlBodyOmitted'
| 'isBodyTruncated'
| 'attachments'
> {
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
readonly attachments: MailAttachment[];
readonly rawHtmlBody: string | null;
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
readonly isRawHtmlBodyTruncated: boolean;
@@ -140,6 +155,18 @@ export interface OutgoingAttachment {
readonly type: string;
}
/**
* 메일 안에 함께 싣는 그림 (서명 그림) — 서식 본문이 `cid:{cid}` 로 가리킨다.
* 어댑터는 서식 본문과 함께 multipart/related 로 묶는다 (계약 2절 "서식 본문"의 그림 예외).
*/
export interface OutgoingInlineImage {
/** Content-ID (꺾쇠 괄호 없이) — 같은 그림이면 늘 같은 값이다 (발송 지문이 서식 본문에 든 cid 를 본다) */
readonly cid: string;
readonly type: string;
readonly name: string;
readonly data: Buffer;
}
export interface OutgoingMessage {
readonly to: MailAddress[];
readonly cc: MailAddress[];
@@ -153,6 +180,8 @@ export interface OutgoingMessage {
*/
readonly htmlBody?: string;
readonly attachments: OutgoingAttachment[];
/** 서식 본문이 cid 로 가리키는 서명 그림 — 있으면 multipart/related 로 함께 싣는다 */
readonly inlineImages?: readonly OutgoingInlineImage[];
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
readonly inReplyTo?: string;
/**
@@ -164,6 +193,84 @@ export interface OutgoingMessage {
readonly fingerprint?: string;
}
/**
* 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/api/mail-signature/images/{id}` 주소 그대로 둔다.
* 발송 중복 방지용 Message-ID·지문은 넣지 않는다 (계약 2절 "임시 저장").
*/
export type DraftContent = Pick<
OutgoingMessage,
| 'to'
| 'cc'
| 'bcc'
| 'subject'
| 'body'
| 'htmlBody'
| 'attachments'
| 'inReplyTo'
>;
/** 저장한 임시 저장본 — 저장할 때마다 id·첨부 id 가 바뀐다 */
export interface SavedDraft {
readonly id: string;
readonly attachments: MailAttachment[];
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
readonly updatedAt: string;
}
/**
* 어댑터가 돌려주는 임시 저장본 원본 — HTML 은 정화 전 값이다(다른 메일 프로그램이 만든 초안일 수 있다).
* 서비스가 보내는 정화를 거친 뒤에만 Draft 로 내보낸다.
*/
export interface DraftSource {
readonly id: string;
readonly to: MailAddress[];
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
readonly subject: string;
readonly textBody: string | null;
readonly rawHtmlBody: string | null;
readonly attachments: MailAttachment[];
/** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */
readonly inReplyTo: string | null;
readonly updatedAt: string;
}
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
export interface Draft {
readonly id: string;
readonly to: MailAddress[];
readonly cc: MailAddress[];
readonly bcc: MailAddress[];
readonly subject: string;
readonly body: string;
/** 보내는 정화를 거친 서식 본문 — 없거나 비면 null */
readonly bodyHtml: string | null;
readonly attachments: MailAttachment[];
readonly inReplyTo: string | null;
readonly updatedAt: string;
}
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
export interface Correspondence {
readonly name: string | null;
readonly email: string;
/** 그 메일의 받은 시각 (ISO 8601 UTC) */
readonly at: string;
}
/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */
export interface DirectoryContact {
readonly email: string;
readonly name: string;
}
/** 받는 사람 추천 (계약 2절 "받는 사람 추천") */
export interface RecipientSuggestion {
readonly name: string | null;
readonly email: string;
readonly source: 'directory' | 'recent';
}
/** 같은 Message-ID 로 이미 발송한 메일 */
export interface SentMessageRecord {
/** 보낸편지함의 메일 id */
@@ -151,10 +151,12 @@ export class MessageController {
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
'보내는 사람 자신의 서명 그림(<img src="/api/mail-signature/images/{id}">)만 남겨 메일 안에 함께 싣는다(multipart/related, cid:). ' +
'draftId(이어 쓴 임시 저장본)가 있으면 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 그 초안을 지운다(지우지 못해도 발송은 성공). ' +
`본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` +
`bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` +
'엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' +
'이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
'이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
})
@ApiHeader({
name: 'Idempotency-Key',
@@ -169,7 +171,7 @@ export class MessageController {
})
@ApiErrorResponse(
'MAIL_002',
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식·draftId 형식 · FILE_001 첨부 형식 (모두 400)`,
)
@ApiErrorResponse(
'MAIL_007',
@@ -0,0 +1,185 @@
import type { Correspondence } from './mail.types';
import {
MAX_RECIPIENT_SUGGESTIONS,
filterRecipientSuggestions,
rankRecipientSuggestions,
} from './recipient-ranking';
const NOW = new Date('2026-10-27T12:00:00.000Z');
const DAY = 24 * 60 * 60 * 1000;
const ago = (days: number) =>
new Date(NOW.getTime() - days * DAY).toISOString();
const ME = 'ara.kim@arasystem.test';
const EXCLUDED = new Set([
'admin@arasystem.test',
'mail-gateway@arasystem.test',
'blocked@arasystem.test',
]);
const sent = (
email: string,
days: number,
name: string | null = null,
): Correspondence => ({
email,
name,
at: ago(days),
});
const rank = (
correspondence: Correspondence[],
directory: { email: string; name: string }[] = [],
) =>
rankRecipientSuggestions({
me: ME,
excluded: EXCLUDED,
directory,
correspondence,
now: NOW,
});
describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
it('사내 → 최근 주고받은 주소 순이고, 두 쪽에 다 있는 주소는 사내로 한 번만 나온다', () => {
const result = rank(
[sent('partner@vendor.example', 0), sent('doyun.lee@arasystem.test', 0)],
[
{ email: 'Doyun.Lee@arasystem.test', name: '이도윤' },
{ email: 'seojun.park@arasystem.test', name: '박서준' },
],
);
expect(result).toEqual([
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'directory',
},
{
name: '박서준',
email: 'seojun.park@arasystem.test',
source: 'directory',
},
{ name: null, email: 'partner@vendor.example', source: 'recent' },
]);
});
it('묶음 안에서는 횟수와 최근성으로 — 오래된 한 번은 반감기(30일)로 줄어든다', () => {
const result = rank([
// 60일 전 3번 = 0.75 · 오늘 1번 = 1 · 60일 전 5번 = 1.25
...Array.from({ length: 3 }, () => sent('old-three@x.example', 60)),
sent('today-once@x.example', 0),
...Array.from({ length: 5 }, () => sent('old-five@x.example', 60)),
]);
expect(result.map((item) => item.email)).toEqual([
'old-five@x.example',
'today-once@x.example',
'old-three@x.example',
]);
});
it('사내 안에서도 주고받은 사람이 먼저고, 나머지는 이름 순이다', () => {
const result = rank(
[sent('seojun.park@arasystem.test', 1)],
[
{ email: 'ga@arasystem.test', name: '가나다' },
{ email: 'seojun.park@arasystem.test', name: '박서준' },
{ email: 'ha@arasystem.test', name: '하늘' },
],
);
expect(result.map((item) => item.name)).toEqual([
'박서준',
'가나다',
'하늘',
]);
});
it('나·게이트웨이·관리자·막은 계정·noreply 류·주소가 아닌 값은 뺀다 (대소문자 무시)', () => {
const result = rank(
[
sent('ARA.KIM@arasystem.test', 0),
sent('admin@arasystem.test', 0),
sent('mail-gateway@arasystem.test', 0),
sent('blocked@arasystem.test', 0),
...[
'noreply@service.example',
'no-reply@service.example',
'no_reply@service.example',
'NoReply+alerts@service.example',
'donotreply@service.example',
'do-not-reply@service.example',
'mailer-daemon@service.example',
].map((email) => sent(email, 0)),
sent('not-an-address', 0),
sent('@nolocal.example', 0),
sent('kept@service.example', 0),
],
[{ email: 'admin@arasystem.test', name: '관리자' }],
);
expect(result).toEqual([
{ name: null, email: 'kept@service.example', source: 'recent' },
]);
});
it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => {
const result = rank(
[
sent('partner@vendor.example', 10, '옛 이름'),
sent('partner@vendor.example', 1, '새 이름'),
sent('partner@vendor.example', 0, ' '),
sent('colleague@arasystem.test', 0, '메일에 적힌 이름'),
],
[{ email: 'colleague@arasystem.test', name: ' ' }],
);
expect(result).toEqual([
{
name: '메일에 적힌 이름',
email: 'colleague@arasystem.test',
source: 'directory',
},
{ name: '새 이름', email: 'partner@vendor.example', source: 'recent' },
]);
});
it(`한 사람에게 ${MAX_RECIPIENT_SUGGESTIONS}개까지만 기억한다`, () => {
const result = rank(
Array.from({ length: MAX_RECIPIENT_SUGGESTIONS + 50 }, (_, i) =>
sent(`person${i}@x.example`, i % 30),
),
);
expect(result).toHaveLength(MAX_RECIPIENT_SUGGESTIONS);
});
});
describe('filterRecipientSuggestions (q 로 거르기)', () => {
const list = [
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'directory' as const,
},
{ name: null, email: 'Partner@Vendor.example', source: 'recent' as const },
];
it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => {
expect(filterRecipientSuggestions(list, '도윤')).toEqual([list[0]]);
expect(filterRecipientSuggestions(list, ' DOYUN ')).toEqual([list[0]]);
expect(filterRecipientSuggestions(list, 'vendor')).toEqual([list[1]]);
expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]);
});
it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => {
expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([
list[0],
]);
});
it('q 가 없거나 공백뿐이면 그대로 준다', () => {
expect(filterRecipientSuggestions(list, undefined)).toBe(list);
expect(filterRecipientSuggestions(list, ' ')).toBe(list);
});
});
@@ -0,0 +1,161 @@
import type {
Correspondence,
DirectoryContact,
RecipientSuggestion,
} from './mail.types';
/**
* 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천".
*
* - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다
* - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기)
* - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다
*/
/** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */
const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
/** 한 사람에게 기억하는 추천 수 상한 */
export const MAX_RECIPIENT_SUGGESTIONS = 1000;
/** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */
const NOREPLY_LOCAL_PART =
/^(?:no[-_.]?reply|do[-_.]?not[-_.]?reply|mailer[-_.]?daemon)(?:[-_.+].*)?$/i;
export interface RankingInput {
/** 요청한 직원 주소 */
readonly me: string;
/** 추천하지 않는 주소 — 게이트웨이·메일 엔진 관리자·막은 계정 (소문자) */
readonly excluded: ReadonlySet<string>;
readonly directory: readonly DirectoryContact[];
readonly correspondence: readonly Correspondence[];
readonly now: Date;
}
interface Tally {
score: number;
latestAt: number;
/** 가장 최근에 본 표시 이름 (비어 있지 않은 것) */
name: string | null;
nameAt: number;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function cleanName(name: string | null | undefined): string | null {
const trimmed = name?.trim();
return trimmed ? trimmed : null;
}
/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */
export function isSuggestable(
email: string,
me: string,
excluded: ReadonlySet<string>,
): boolean {
const at = email.lastIndexOf('@');
if (at <= 0 || at === email.length - 1) return false;
if (email === me || excluded.has(email)) return false;
return !NOREPLY_LOCAL_PART.test(email.slice(0, at));
}
function tally(
correspondence: readonly Correspondence[],
now: number,
): Map<string, Tally> {
const tallies = new Map<string, Tally>();
for (const item of correspondence) {
const email = normalizeEmail(item.email);
const at = Date.parse(item.at);
const when = Number.isNaN(at) ? 0 : at;
const age = Math.max(0, now - when);
const weight = Math.pow(0.5, age / RECENCY_HALF_LIFE_MS);
const entry = tallies.get(email) ?? {
score: 0,
latestAt: 0,
name: null,
nameAt: -1,
};
entry.score += weight;
entry.latestAt = Math.max(entry.latestAt, when);
const name = cleanName(item.name);
if (name !== null && when >= entry.nameAt) {
entry.name = name;
entry.nameAt = when;
}
tallies.set(email, entry);
}
return tallies;
}
const byName = new Intl.Collator('ko');
/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */
export function rankRecipientSuggestions(
input: RankingInput,
): RecipientSuggestion[] {
const me = normalizeEmail(input.me);
const tallies = tally(input.correspondence, input.now.getTime());
const scoreOf = (email: string) => tallies.get(email)?.score ?? 0;
const directory = new Map<string, string | null>();
for (const contact of input.directory) {
const email = normalizeEmail(contact.email);
if (!isSuggestable(email, me, input.excluded) || directory.has(email)) {
continue;
}
directory.set(
email,
cleanName(contact.name) ?? tallies.get(email)?.name ?? null,
);
}
const colleagues: RecipientSuggestion[] = [...directory]
.map(([email, name]) => ({ name, email, source: 'directory' as const }))
.sort(
(a, b) =>
scoreOf(b.email) - scoreOf(a.email) ||
byName.compare(a.name ?? a.email, b.name ?? b.email) ||
a.email.localeCompare(b.email),
);
const recent: RecipientSuggestion[] = [...tallies]
.filter(
([email]) =>
!directory.has(email) && isSuggestable(email, me, input.excluded),
)
.sort(
([emailA, a], [emailB, b]) =>
b.score - a.score ||
b.latestAt - a.latestAt ||
emailA.localeCompare(emailB),
)
.map(([email, entry]) => ({
name: entry.name,
email,
source: 'recent' as const,
}));
return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS);
}
/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */
function folded(text: string): string {
return text.normalize('NFC').toLowerCase();
}
/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */
export function filterRecipientSuggestions(
suggestions: readonly RecipientSuggestion[],
query: string | undefined,
): readonly RecipientSuggestion[] {
const q = query ? folded(query.trim()) : '';
if (q === '') return suggestions;
return suggestions.filter(
(suggestion) =>
folded(suggestion.email).includes(q) ||
(suggestion.name !== null && folded(suggestion.name).includes(q)),
);
}
@@ -0,0 +1,70 @@
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeSuggestionCursor,
encodeSuggestionCursor,
} from './recipient-suggestion-cursor';
function reasonOf(run: () => unknown): unknown {
try {
run();
} catch (error) {
if (error instanceof BusinessException && error.code === 'VAL_001') {
return error.context?.reason;
}
throw error;
}
return undefined;
}
const encoded = (payload: unknown) =>
Buffer.from(JSON.stringify(payload)).toString('base64url');
describe('받는 사람 추천 커서', () => {
it('같은 검색어로 되돌리면 위치가 그대로다 (검색어 앞뒤 공백은 부르는 쪽이 지운다)', () => {
for (const q of [undefined, '도윤', 'partner.test']) {
expect(decodeSuggestionCursor(encodeSuggestionCursor(40, q), q)).toBe(40);
}
// 검색어 없음과 빈 검색어는 같은 범위다
expect(
decodeSuggestionCursor(encodeSuggestionCursor(3, undefined), ''),
).toBe(3);
});
it('다른 검색어의 커서는 VAL_001 — 걸러진 목록이 달라 위치가 맞지 않는다', () => {
const cursor = encodeSuggestionCursor(20, '도윤');
expect(reasonOf(() => decodeSuggestionCursor(cursor, '서준'))).toBe(
'invalid-suggestion-cursor:other-query',
);
expect(reasonOf(() => decodeSuggestionCursor(cursor, undefined))).toBe(
'invalid-suggestion-cursor:other-query',
);
});
it('형식이 틀리면 VAL_001 — 너무 김 · JSON 아님 · 모르는 판 · 음수·소수 위치', () => {
const scope = JSON.parse(
Buffer.from(encodeSuggestionCursor(0, undefined), 'base64url').toString(
'utf8',
),
) as { s: string };
expect(
reasonOf(() => decodeSuggestionCursor('x'.repeat(257), undefined)),
).toBe('invalid-suggestion-cursor:too-long');
expect(reasonOf(() => decodeSuggestionCursor('@@@', undefined))).toBe(
'invalid-suggestion-cursor:not-json',
);
for (const payload of [
null,
{ v: 2, o: 0, s: scope.s },
{ v: 1, o: -1, s: scope.s },
{ v: 1, o: 1.5, s: scope.s },
{ v: 1, o: '1', s: scope.s },
{ v: 1, o: 1 },
]) {
expect(
reasonOf(() => decodeSuggestionCursor(encoded(payload), undefined)),
).toBe('invalid-suggestion-cursor:malformed');
}
});
});
@@ -0,0 +1,69 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치.
* 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식).
* 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다).
*/
const CURSOR_VERSION = 1;
const MAX_CURSOR_LENGTH = 256;
const SCOPE_HASH_LENGTH = 16;
interface EncodedCursor {
v: number;
o: number;
s: string;
}
function scopeHash(query: string | undefined): string {
return createHash('sha256')
.update(query ?? '')
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-suggestion-cursor:${reason}` },
});
}
export function encodeSuggestionCursor(
offset: number,
query: string | undefined,
): string {
const payload: EncodedCursor = {
v: CURSOR_VERSION,
o: offset,
s: scopeHash(query),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */
export function decodeSuggestionCursor(
cursor: string,
query: string | undefined,
): number {
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, o, s } = (parsed ?? {}) as Partial<EncodedCursor>;
const valid =
v === CURSOR_VERSION &&
typeof o === 'number' &&
Number.isSafeInteger(o) &&
o >= 0 &&
typeof s === 'string';
if (!valid) throw invalidCursor('malformed');
if (s !== scopeHash(query)) throw invalidCursor('other-query');
return o;
}
@@ -0,0 +1,47 @@
import { Controller, Get, Header, Query, 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 { RecipientSuggestionPageDto } from './dto/mail-response.dto';
import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
import { RecipientSuggestionService } from './recipient-suggestions.service';
// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다.
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller('recipient-suggestions')
export class RecipientSuggestionController {
constructor(private readonly suggestions: RecipientSuggestionService) {}
@Get()
// 주고받은 사람 목록 — 공유 캐시·브라우저 캐시 어디에도 남기지 않는다
@Header('Cache-Control', 'private, no-store')
@ApiOperation({
summary: '받는 사람 추천 (커서 페이지네이션)',
description:
'사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' +
'묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' +
'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.',
})
@ApiStandardResponse(RecipientSuggestionPageDto, {
description: '추천 목록 (total 은 걸러진 개수)',
})
@ApiErrorResponse('VAL_001', 'q 100자 초과 · 잘못된 커서 · limit 범위 밖')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: RecipientSuggestionQueryDto,
) {
return this.suggestions.suggest(user, query);
}
}
@@ -0,0 +1,310 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { Correspondence, DirectoryContact } from './mail.types';
import {
RecipientSuggestionService,
SUGGESTION_CACHE_MAX_USERS,
SUGGESTION_CACHE_TTL_MS,
} from './recipient-suggestions.service';
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' };
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']),
};
const NOW = Date.parse('2026-10-27T03:00:00Z');
const daysAgo = (days: number) =>
new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
const query = (
overrides: Partial<RecipientSuggestionQueryDto> = {},
): RecipientSuggestionQueryDto => ({ limit: 20, ...overrides });
describe('RecipientSuggestionService', () => {
let listCorrespondents: jest.Mock<
Promise<Correspondence[]>,
Parameters<MailProvider['listCorrespondents']>
>;
let listContacts: jest.Mock<Promise<DirectoryContact[]>, [number?]>;
let service: RecipientSuggestionService;
let debugSpy: jest.SpyInstance;
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW);
listCorrespondents = jest
.fn<
Promise<Correspondence[]>,
Parameters<MailProvider['listCorrespondents']>
>()
.mockResolvedValue([
{ name: '고객사', email: 'client@partner.test', at: daysAgo(1) },
{ name: null, email: 'Client@Partner.test', at: daysAgo(2) },
{ name: '이도윤', email: 'doyun.lee@arasystem.test', at: daysAgo(1) },
{ name: null, email: 'vendor@supply.test', at: daysAgo(90) },
// 나·게이트웨이·관리자·자동 발송 주소는 추천하지 않는다
{ name: '나', email: 'ARA.KIM@arasystem.test', at: daysAgo(0) },
{ name: null, email: 'mail-gateway@arasystem.test', at: daysAgo(0) },
{ name: null, email: 'admin@arasystem.test', at: daysAgo(0) },
{ name: null, email: 'no-reply@service.test', at: daysAgo(0) },
]);
listContacts = jest
.fn<Promise<DirectoryContact[]>, [number?]>()
.mockResolvedValue([
{ email: 'seojun.park@arasystem.test', name: '박서준' },
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
{ email: 'ara.kim@arasystem.test', name: '김아라' },
]);
service = new RecipientSuggestionService(
{ listCorrespondents } as unknown as MailProvider,
settings,
{ listContacts } as unknown as MailDirectoryService,
);
debugSpy = jest
.spyOn(Logger.prototype, 'debug')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('사내 → 최근 순으로, 그 직원 자신의 메일함에서 모은다 — 나·게이트웨이·관리자·noreply 는 빼고, 두 쪽에 다 있으면 사내로 한 번', async () => {
const page = await service.suggest(ARA, query());
expect(page.items).toEqual([
// 사내 묶음 안에서는 주고받은 사람이 먼저
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'directory',
},
{
name: '박서준',
email: 'seojun.park@arasystem.test',
source: 'directory',
},
// 대소문자만 다른 주소는 한 사람 — 두 번 주고받았다
{ name: '고객사', email: 'client@partner.test', source: 'recent' },
{ name: null, email: 'vendor@supply.test', source: 'recent' },
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 4,
});
expect(listCorrespondents).toHaveBeenCalledWith('ara.kim@arasystem.test', {
sent: 500,
received: 500,
});
});
it('커서 페이지네이션 — 걸러진 목록의 다음 위치, total 은 걸러진 개수', async () => {
const first = await service.suggest(ARA, query({ limit: 3 }));
expect(first.items).toHaveLength(3);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
const cursor = first.pageInfo.nextCursor;
if (!cursor) throw new Error('다음 커서가 없다');
const second = await service.suggest(ARA, query({ limit: 3, cursor }));
expect(second.items.map((item) => item.email)).toEqual([
'vendor@supply.test',
]);
expect(second.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 4,
});
});
it('q 는 이름·주소에 든 것만 (대소문자 무시) — 다른 q 의 커서는 VAL_001', async () => {
const page = await service.suggest(
ARA,
query({ q: ' PARTNER ', limit: 1 }),
);
expect(page.items.map((item) => item.email)).toEqual([
'client@partner.test',
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 1,
});
const byName = await service.suggest(ARA, query({ q: '서준', limit: 1 }));
expect(byName.items.map((item) => item.email)).toEqual([
'seojun.park@arasystem.test',
]);
const all = await service.suggest(ARA, query({ limit: 1 }));
const cursor = all.pageInfo.nextCursor ?? '';
await expect(
service.suggest(ARA, query({ q: '이', cursor })),
).rejects.toMatchObject({
code: 'VAL_001',
context: { reason: 'invalid-suggestion-cursor:other-query' },
});
});
it('틀린 커서는 엔진에 묻기 전에 VAL_001', async () => {
for (const cursor of ['not-a-cursor', 'x'.repeat(300)]) {
const error = await service
.suggest(ARA, query({ cursor }))
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).code).toBe('VAL_001');
}
expect(listCorrespondents).not.toHaveBeenCalled();
expect(listContacts).not.toHaveBeenCalled();
});
it('사용자마다 10분 기억한다 — 그 안에서는 다시 모으지 않고, 지나면 다시 모은다', async () => {
await service.suggest(ARA, query());
await service.suggest({ ...ARA, email: 'Ara.Kim@arasystem.test' }, query());
expect(listCorrespondents).toHaveBeenCalledTimes(1);
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
await service.suggest(ARA, query());
expect(listCorrespondents).toHaveBeenCalledTimes(2);
// 다른 사람은 따로 모은다 (주고받은 주소가 섞이지 않게)
await service.suggest(
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
query(),
);
expect(listCorrespondents).toHaveBeenLastCalledWith(
'doyun.lee@arasystem.test',
{ sent: 500, received: 500 },
);
});
it('메일을 보내면(invalidate) 잊는다 — 다음 요청에 다시 모은다', async () => {
await service.suggest(ARA, query());
service.invalidate('ARA.KIM@arasystem.test');
await service.suggest(ARA, query());
expect(listCorrespondents).toHaveBeenCalledTimes(2);
});
it('같은 사람의 동시 요청은 한 번만 모은다', async () => {
let finish!: (value: Correspondence[]) => void;
listCorrespondents.mockImplementationOnce(
() => new Promise((resolve) => (finish = resolve)),
);
const first = service.suggest(ARA, query());
const second = service.suggest(ARA, query({ q: '도윤' }));
await new Promise((resolve) => setImmediate(resolve));
finish([]);
await expect(first).resolves.toMatchObject({ pageInfo: { total: 2 } });
await expect(second).resolves.toMatchObject({ pageInfo: { total: 1 } });
expect(listCorrespondents).toHaveBeenCalledTimes(1);
});
it('모으는 중에 보냈으면 그 결과는 기억하지 않는다 — 보낸 뒤의 요청은 새로 모은다', async () => {
let finish!: (value: Correspondence[]) => void;
listCorrespondents.mockImplementationOnce(
() => new Promise((resolve) => (finish = resolve)),
);
const before = service.suggest(ARA, query());
await new Promise((resolve) => setImmediate(resolve));
service.invalidate(ARA.email);
const after = service.suggest(ARA, query());
finish([]);
await before;
await after;
expect(listCorrespondents).toHaveBeenCalledTimes(2);
// 보낸 뒤에 모은 결과는 기억한다
await service.suggest(ARA, query());
expect(listCorrespondents).toHaveBeenCalledTimes(2);
});
it('엔진 장애는 그대로 던지고 기억하지 않는다 — 다음 요청은 다시 묻는다', async () => {
listCorrespondents.mockRejectedValueOnce(new BusinessException('MAIL_001'));
await expect(service.suggest(ARA, query())).rejects.toMatchObject({
code: 'MAIL_001',
});
await expect(service.suggest(ARA, query())).resolves.toMatchObject({
pageInfo: { total: 4 },
});
expect(listCorrespondents).toHaveBeenCalledTimes(2);
});
it(`기억하는 사람은 ${SUGGESTION_CACHE_MAX_USERS}명까지 — 가장 오래 쓰지 않은 사람부터 잊는다`, async () => {
const userAt = (index: number) => ({
email: `user${index}@arasystem.test`,
name: `직원${index}`,
});
for (let index = 0; index < SUGGESTION_CACHE_MAX_USERS; index += 1) {
await service.suggest(userAt(index), query());
}
// 0번을 다시 쓴다 — 이제 가장 오래 쓰지 않은 사람은 1번이다
await service.suggest(userAt(0), query());
await service.suggest(userAt(SUGGESTION_CACHE_MAX_USERS), query());
listCorrespondents.mockClear();
await service.suggest(userAt(0), query());
expect(listCorrespondents).not.toHaveBeenCalled();
await service.suggest(userAt(1), query());
expect(listCorrespondents).toHaveBeenCalledTimes(1);
});
it('기억할 때 만료된 목록을 함께 지운다 — 다시 오지 않은 사람의 목록이 남지 않는다', async () => {
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
await service.suggest(ARA, query());
expect(cache.has(ARA.email)).toBe(true);
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
await service.suggest(
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
query(),
);
expect([...cache.keys()]).toEqual(['doyun.lee@arasystem.test']);
});
it('줄지 않는 표를 두지 않는다 — 모으기가 끝나면 빠지고, 잊기만 한 사람은 아무 데도 남지 않는다', async () => {
const collecting = Reflect.get(service, 'collecting') as Map<
string,
unknown
>;
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
await service.suggest(ARA, query());
expect(collecting.size).toBe(0);
for (let index = 0; index < 1_000; index += 1) {
service.invalidate(`user${index}@arasystem.test`);
}
service.invalidate(ARA.email);
expect(collecting.size).toBe(0);
expect(cache.size).toBe(0);
// 잊은 횟수를 사람마다 세던 표(generations)는 없앴다
expect(Reflect.get(service, 'generations')).toBeUndefined();
});
it('로그에는 개수만 남긴다 — 주소·이름·검색어 없음', async () => {
await service.suggest(ARA, query({ q: '고객' }));
const logged = debugSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('사내 2명, 최근 2명');
expect(logged).not.toMatch(/partner|doyun|seojun|고객|이도윤/);
});
});
@@ -0,0 +1,181 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import { traceTag } from '@/shared/logger/request-context';
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
import {
SUGGESTION_RECEIVED_SCAN,
SUGGESTION_SENT_SCAN,
} from './mail.constants';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type { RecipientSuggestion } from './mail.types';
import {
filterRecipientSuggestions,
rankRecipientSuggestions,
} from './recipient-ranking';
import {
decodeSuggestionCursor,
encodeSuggestionCursor,
} from './recipient-suggestion-cursor';
/** 사용자별 추천 목록을 기억하는 시간 */
export const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
/**
* 기억하는 사용자 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다.
* 한 사람의 목록은 1,000개까지라(recipient-ranking.ts) 사람당 수십~백수십 KB — 상한이 메모리 상한이다.
*/
export const SUGGESTION_CACHE_MAX_USERS = 500;
interface CacheEntry {
readonly expiresAt: number;
readonly items: readonly RecipientSuggestion[];
}
/**
* 모으는 중인 목록. `token` 은 이 모으기의 표 — 그 사람을 잊으면(invalidate) 표에서 빠지므로,
* 끝났을 때 표가 그대로인 모으기만 결과를 기억한다 (잊기 전에 시작한 모으기의 옛 결과를 남기지 않는다).
*/
interface Collecting {
readonly token: object;
readonly promise: Promise<readonly RecipientSuggestion[]>;
}
function normalizeUser(email: string): string {
return email.trim().toLowerCase();
}
/**
* 받는 사람 추천 (계약 2절 "받는 사람 추천") — 사내 목록 + 그 사람이 최근 주고받은 주소.
*
* - 주고받은 주소는 **요청한 직원 자신의** 메일함에서만 모은다 (대리 접근이 그 사람으로만 들어간다). 다른 직원의 것과 섞지 않는다
* - 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다
* - 로그에는 개수만 남긴다 — 주소·검색어는 남기지 않는다
*
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 (틀리지는 않고, 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 줄 수 있다).
*/
@Injectable()
export class RecipientSuggestionService {
private readonly logger = new Logger(RecipientSuggestionService.name);
private readonly cache = new Map<string, CacheEntry>();
/** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */
private readonly collecting = new Map<string, Collecting>();
private readonly excluded: ReadonlySet<string>;
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) settings: MailSettings,
private readonly directory: MailDirectoryService,
) {
this.excluded = new Set([
...settings.blockedAccounts,
...(settings.gatewayUser ? [settings.gatewayUser] : []),
]);
}
async suggest(
user: AuthenticatedUser,
query: RecipientSuggestionQueryDto,
): Promise<PaginatedResult<RecipientSuggestion>> {
const q = query.q?.trim() ? query.q.trim() : undefined;
// 커서는 엔진에 묻기 전에 확인한다 — 틀린 커서로 목록을 모으지 않게
const start = query.cursor ? decodeSuggestionCursor(query.cursor, q) : 0;
const matched = filterRecipientSuggestions(
await this.suggestionsOf(user.email),
q,
);
const items = matched.slice(start, start + query.limit);
const next = start + items.length;
const hasNext = next < matched.length;
return toPaginatedResult([...items], {
hasNext,
nextCursor: hasNext ? encodeSuggestionCursor(next, q) : null,
total: matched.length,
});
}
/** 그 사람이 메일을 보냈다 — 기억한 목록을 잊는다 (다음 요청에 다시 모은다) */
invalidate(userEmail: string): void {
const key = normalizeUser(userEmail);
this.cache.delete(key);
// 모으는 중이던 것도 잊는다 — 끝나도 기억하지 않고, 이 뒤의 요청은 보낸 메일까지 넣어 새로 모은다
this.collecting.delete(key);
}
private suggestionsOf(
userEmail: string,
): Promise<readonly RecipientSuggestion[]> {
const key = normalizeUser(userEmail);
const cached = this.cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
// 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다
this.cache.delete(key);
this.cache.set(key, cached);
return Promise.resolve(cached.items);
}
const pending = this.collecting.get(key);
if (pending) return pending.promise;
const token = {};
const promise = this.collect(key, token).finally(() => {
// 그사이 잊고 새로 모으기 시작했으면 그것은 그대로 둔다
if (this.collecting.get(key)?.token === token)
this.collecting.delete(key);
});
this.collecting.set(key, { token, promise });
return promise;
}
private async collect(
user: string,
token: object,
): Promise<readonly RecipientSuggestion[]> {
const [correspondence, directory] = await Promise.all([
this.provider.listCorrespondents(user, {
sent: SUGGESTION_SENT_SCAN,
received: SUGGESTION_RECEIVED_SCAN,
}),
this.directory.listContacts(),
]);
const items = rankRecipientSuggestions({
me: user,
excluded: this.excluded,
directory,
correspondence,
now: new Date(),
});
// 모으는 사이 잊었으면(그 사람이 메일을 보냈다) 기억하지 않는다
if (this.collecting.get(user)?.token === token) this.remember(user, items);
// 반복되는 정상 흐름이라 debug — 개수만 (주소·검색어는 남기지 않는다)
const colleagues = items.filter(
(item) => item.source === 'directory',
).length;
this.logger.debug(
`${traceTag()} 받는 사람 추천을 모았습니다: 사내 ${colleagues}명, 최근 ${items.length - colleagues}명`,
);
return items;
}
private remember(user: string, items: readonly RecipientSuggestion[]): void {
const now = Date.now();
// 만료된 목록은 그때그때 지운다 — 다시 오지 않은 사람의 목록이 상한까지 쌓여 메모리를 잡지 않게
for (const [key, entry] of this.cache) {
if (entry.expiresAt <= now) this.cache.delete(key);
}
this.cache.delete(user);
this.cache.set(user, { expiresAt: now + SUGGESTION_CACHE_TTL_MS, items });
while (this.cache.size > SUGGESTION_CACHE_MAX_USERS) {
const oldest = this.cache.keys().next().value;
if (oldest === undefined) break;
this.cache.delete(oldest);
}
}
}
+39 -1
View File
@@ -1,4 +1,42 @@
import { maskEmail } from './mask';
import { maskEmail, maskUrlForLog } from './mask';
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
expect(
maskUrlForLog(
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
),
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
// + 는 공백 · 주소 형태의 검색어
expect(
maskUrlForLog(
'/api/messages?mailboxId=a&q=ara.kim%40arasystem.it.kr+%EC%9D%BC%EC%A0%95',
),
).toBe('/api/messages?mailboxId=a&q=<26자>');
});
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
expect(
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
expect(
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
});
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
'/api/messages?mailboxId=a&limit=20',
);
// q 로 시작하는 다른 이름은 가리지 않는다
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
// 값 안의 = 도 값이다
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
});
});
describe('maskEmail', () => {
it('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
+40
View File
@@ -8,6 +8,21 @@
/** 가린 부분을 표시하는 문자열 — 원래 길이를 드러내지 않도록 고정 길이로 쓴다 */
const MASK = '***';
/**
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
*/
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
try {
return decodeURIComponent(part.replace(/\+/g, ' '));
} catch {
return part;
}
}
/**
* 이메일 주소를 로그용으로 가린다. 첫 글자와 도메인만 남긴다.
*
@@ -22,3 +37,28 @@ export function maskEmail(email: string): string {
const [first] = Array.from(email.slice(0, at));
return `${first}${MASK}@${email.slice(at + 1)}`;
}
/**
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
*/
export function maskUrlForLog(url: string): string {
const queryStart = url.indexOf('?');
if (queryStart < 0) return url;
const query = url
.slice(queryStart + 1)
.split('&')
.map((pair) => {
const separator = pair.indexOf('=');
const name = separator < 0 ? pair : pair.slice(0, separator);
if (!REDACTED_QUERY_NAME.test(decodeQueryPart(name))) return pair;
const value =
separator < 0 ? '' : decodeQueryPart(pair.slice(separator + 1));
return `${name}=<${value.length}자>`;
})
.join('&');
return `${url.slice(0, queryStart)}?${query}`;
}
+32
View File
@@ -0,0 +1,32 @@
import { settledWithin } from './settle';
describe('settledWithin (정해진 시간까지만 기다리기)', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('시간 안에 끝나면 true — 실패로 끝나도 끝난 것이다', async () => {
await expect(settledWithin(Promise.resolve('done'), 1_000)).resolves.toBe(
true,
);
const failed = Promise.reject(new Error('engine down'));
await expect(settledWithin(failed, 1_000)).resolves.toBe(true);
});
it('시간이 지나면 false — 작업은 멈추지 않고 뒤에서 끝난다', async () => {
let finish!: () => void;
let finished = false;
const work = new Promise<void>((resolve) => {
finish = resolve;
}).then(() => {
finished = true;
});
const waited = settledWithin(work, 5_000);
jest.advanceTimersByTime(5_000);
await expect(waited).resolves.toBe(false);
finish();
await work;
expect(finished).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* 정해진 시간까지만 기다린다 — 그 안에 끝나면 true, 아니면 false (작업은 멈추지 않고 뒤에서 계속된다).
* 작업이 실패해도 "끝난 것"으로 본다 — 실패 처리(로그 등)는 작업 쪽이 한다. 이 함수는 결과를 바꾸지 않는다.
* 기다림 타이머는 프로세스를 붙잡지 않는다(unref) — 종료가 늦어지지 않게.
*/
export function settledWithin(
work: Promise<unknown>,
ms: number,
): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), ms);
timer.unref();
const finish = () => {
clearTimeout(timer);
resolve(true);
};
work.then(finish, finish);
});
}
+774 -2
View File
@@ -12,6 +12,7 @@ import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { configureApp } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import {
MAX_BODY_HTML_LENGTH,
@@ -20,7 +21,22 @@ import {
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
Correspondence,
DraftContent,
DraftSource,
OutgoingMessage,
SavedDraft,
} from '@/modules/mail/mail.types';
import {
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_DIR,
SIGNATURE_IMAGE_MAX_BYTES,
} from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
import { pngBytes } from './image-fixtures';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
@@ -55,6 +71,9 @@ interface ContractBody {
const body = (res: Response): ContractBody => res.body as ContractBody;
/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */
const dataOf = <T>(res: Response): T => (res.body as { data: T }).data;
/** Set-Cookie 헤더 중 이름이 같은 것 */
function setCookie(res: Response, name: string): string | undefined {
const raw = res.headers['set-cookie'] as unknown;
@@ -78,6 +97,15 @@ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
const PROFILE_NAMES = new Map([
['colleague.a@e2e.arasystem.test', '가나다'],
['colleague.b@e2e.arasystem.test', '박서준'],
]);
/** 문을 열 조건을 다시 보는 간격 */
const GATE_POLL_MS = 10;
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
@@ -150,6 +178,18 @@ describe('응답 계약 (e2e)', () => {
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
*/
let slowEngineGate: Promise<void> | null = null;
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
const drafts = new Map<
string,
{ user: string; content: DraftContent; updatedAt: string }
>();
let draftSequence = 0;
/** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */
const deletedDrafts: string[] = [];
/** 대역 엔진이 받은 발송 */
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
const correspondents = new Map<string, Correspondence[]>();
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
@@ -188,13 +228,79 @@ describe('응답 계약 (e2e)', () => {
.useValue({
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다
getProfile: (email: string) =>
Promise.resolve({ email, name: EMPLOYEE.name }),
Promise.resolve({
email,
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
}),
verifyCredentials: async (email: string, password: string) => {
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
},
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다
findSentMessage: () => Promise.resolve(null),
sendMessage: (user: string, message: OutgoingMessage) => {
sentMessages.push({ user, message });
return Promise.resolve({ id: `sent${sentMessages.length}` });
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
saveDraft: (
user: string,
content: DraftContent,
replaceId?: string,
): Promise<SavedDraft> => {
if (replaceId === DRAFT_CHANGED_ELSEWHERE) {
return Promise.reject(
new BusinessException('MAIL_008', {
context: { reason: 'e2e-draft-replaced-elsewhere' },
}),
);
}
if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-not-a-draft' },
}),
);
}
draftSequence += 1;
const id = `draft${draftSequence}`;
const updatedAt = new Date().toISOString();
drafts.set(id, { user, content, updatedAt });
if (replaceId !== undefined) drafts.delete(replaceId);
return Promise.resolve({ id, attachments: [], updatedAt });
},
getDraft: (user: string, id: string): Promise<DraftSource> => {
const draft = drafts.get(id);
if (!draft || draft.user !== user) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-not-a-draft' },
}),
);
}
const { content, updatedAt } = draft;
return Promise.resolve({
id,
to: content.to,
cc: content.cc,
bcc: content.bcc,
subject: content.subject,
textBody: content.body,
rawHtmlBody: content.htmlBody ?? null,
attachments: [],
inReplyTo: null,
updatedAt,
});
},
deleteDraft: (user: string, id: string) => {
deletedDrafts.push(id);
if (drafts.get(id)?.user === user) drafts.delete(id);
return Promise.resolve();
},
listCorrespondents: (user: string) =>
Promise.resolve(correspondents.get(user) ?? []),
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -222,6 +328,22 @@ describe('응답 계약 (e2e)', () => {
'DELETE FROM auth_sessions WHERE user_email LIKE $1',
[TEST_DOMAIN_PATTERN],
);
// 서명 그림은 파일도 지운다 (개발 업로드 볼륨에 남기지 않는다)
const images: { file_name: string }[] = await dataSource.query(
'SELECT file_name FROM mail_signature_images WHERE user_email LIKE $1',
[TEST_DOMAIN_PATTERN],
);
for (const { file_name: fileName } of images) {
const stored = join(SIGNATURE_IMAGE_DIR, basename(fileName));
if (existsSync(stored)) unlinkSync(stored);
}
for (const statement of [
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
'DELETE FROM mail_directory WHERE email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
await app.close();
});
@@ -544,6 +666,17 @@ describe('응답 계약 (e2e)', () => {
['get', '/api/messages/eaaaaab'],
['post', '/api/messages'],
['post', '/api/mail-attachments'],
['get', '/api/drafts/eaaaaab'],
['post', '/api/drafts'],
['put', '/api/drafts/eaaaaab'],
['get', '/api/mail-signature'],
['put', '/api/mail-signature'],
['post', '/api/mail-signature/images'],
[
'get',
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
],
['get', '/api/recipient-suggestions'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
@@ -594,7 +727,8 @@ describe('응답 계약 (e2e)', () => {
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
600,
);
// 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다)
// 한도는 엔진에 묻기 전에 본다 — 대역 엔진이 받은 발송이 늘지 않아야 한다
const sentBefore = sentMessages.length;
const send = (overrides: object) =>
request(server)
.post('/api/messages')
@@ -620,6 +754,644 @@ describe('응답 계약 (e2e)', () => {
subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1),
}).expect(400);
expect(body(longSubject).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(sentBefore);
});
});
/**
* 2단계 — 임시 저장 · 서명 · 받는 사람 추천 (계약 2절).
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
*/
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
const cookieOf = (email: string) =>
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
describe('임시 저장 (/api/drafts)', () => {
const save = (method: 'post' | 'put', path: string, payload: object) =>
request(server)
[method](path)
.set('Cookie', cookieOf(EMPLOYEE.email))
.send({ to: [], subject: '쓰는 중', body: '본문', ...payload });
it('받는 사람 없이도 처음 저장(POST)은 201 — 서식 본문은 보내는 정화를 거쳐 엔진에 넘어간다', async () => {
const res = await save('post', '/api/drafts', {
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
}).expect(201);
const saved = dataOf<SavedDraft>(res);
expect(saved).toEqual({
id: expect.stringMatching(/^draft\d+$/) as string,
attachments: [],
updatedAt: expect.any(String) as string,
});
expect(drafts.get(saved.id)?.content).toMatchObject({
to: [],
htmlBody: '<p>쓰는 중</p>',
});
});
it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => {
const first = dataOf<SavedDraft>(
await save('post', '/api/drafts', {}).expect(201),
);
const second = dataOf<SavedDraft>(
await save('put', `/api/drafts/${first.id}`, {
to: [DOYUN],
subject: '고친 제목',
bodyHtml:
'<p><strong>굵게</strong><img src="https://x.test/p.gif"></p>',
}).expect(200),
);
expect(second.id).not.toBe(first.id);
expect(drafts.has(first.id)).toBe(false);
const res = await request(server)
.get(`/api/drafts/${second.id}`)
.set('Cookie', cookieOf(EMPLOYEE.email))
.expect(200);
expect(dataOf<unknown>(res)).toEqual({
id: second.id,
to: [DOYUN],
cc: [],
bcc: [],
subject: '고친 제목',
body: '본문',
bodyHtml: '<p><strong>굵게</strong></p>',
attachments: [],
inReplyTo: null,
updatedAt: second.updatedAt,
});
});
it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => {
const changed = await save(
'put',
`/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`,
{},
).expect(409);
expect(body(changed).error?.code).toBe('MAIL_008');
expect(body(changed).error?.traceId).toBe(
changed.headers['x-request-id'],
);
for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) {
const res = await save('put', path, {}).expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => {
const mine = dataOf<SavedDraft>(
await save('post', '/api/drafts', {}).expect(201),
);
const res = await request(server)
.get(`/api/drafts/${mine.id}`)
.set('Cookie', cookieOf('other.person@e2e.arasystem.test'))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
});
it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => {
const recipients = (count: number) =>
Array.from({ length: count }, (_, index) => ({
name: null,
email: `r${index}@e2e.arasystem.test`,
}));
const cases: [object, number, string][] = [
[{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'],
[{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'],
[{ draftId: 'eaaaaab' }, 400, 'VAL_001'],
];
const before = drafts.size;
for (const [payload, status, code] of cases) {
const res = await save('post', '/api/drafts', payload).expect(status);
expect(body(res).error?.code).toBe(code);
}
expect(drafts.size).toBe(before);
});
it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
for (const [method, path] of [
['post', '/api/drafts'],
['put', '/api/drafts/eaaaaab'],
] as const) {
const parsed = await request(server)
[method](path)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
.expect(401);
expect(body(parsed).error?.code).toBe('AUTH_001');
const tooLarge = await request(server)
[method](path)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
.expect(413);
expect(body(tooLarge).error?.code).toBe('FILE_002');
}
});
it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => {
const draft = dataOf<SavedDraft>(
await save('post', '/api/drafts', { to: [DOYUN] }).expect(201),
);
const send = (draftId: string) =>
request(server)
.post('/api/messages')
.set('Cookie', cookieOf(EMPLOYEE.email))
.send({ to: [DOYUN], subject: '제목', body: '본문', draftId });
const sentBefore = sentMessages.length;
const malformed = await send('../draft1').expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(sentBefore);
await send(draft.id).expect(201);
expect(sentMessages).toHaveLength(sentBefore + 1);
// 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다)
expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId');
expect(deletedDrafts).toContain(draft.id);
expect(drafts.has(draft.id)).toBe(false);
});
});
describe('서명 (/api/mail-signature)', () => {
const imageUrl = (id: string) => `/api/mail-signature/images/${id}`;
const uploadAs = (
email: string,
data: Buffer,
filename = 'logo.png',
contentType = 'image/png',
) =>
request(server)
.post('/api/mail-signature/images')
.set('Cookie', cookieOf(email))
.attach('file', data, { filename, contentType });
const uploaded = async (email: string, data = pngBytes(480, 120)) =>
dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id;
const saveSignature = (email: string, payload: object) =>
request(server)
.put('/api/mail-signature')
.set('Cookie', cookieOf(email))
.send({ isForNew: true, isForReply: true, ...payload });
it('저장한 적 없으면 기본값', async () => {
const res = await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf('sign.default@e2e.arasystem.test'))
.expect(200);
expect(dataOf<unknown>(res)).toEqual({
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
});
});
it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => {
const owner = 'sign.owner@e2e.arasystem.test';
const data = pngBytes(480, 120, 2_048);
const image = dataOf<Record<string, unknown>>(
await uploadAs(owner, data, '회사 로고.png').expect(201),
);
expect(image).toEqual({
id: expect.stringMatching(UUID) as string,
type: 'image/png',
size: 2_048,
width: 480,
height: 120,
});
const id = String(image.id);
const res = await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(owner))
.expect(200);
expect(res.headers['content-type']).toBe('image/png');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['cache-control']).toBe('private, max-age=86400');
expect(res.headers['content-disposition']).toBe(
'inline; filename="signature.png"',
);
expect(res.body).toEqual(data);
for (const [cookie, path] of [
[cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)],
[cookieOf(owner), imageUrl('not-a-uuid')],
[cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')],
]) {
const denied = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(404);
expect(body(denied).error?.code).toBe('RES_001');
}
});
it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => {
const user = 'sign.reject@e2e.arasystem.test';
for (const [data, filename, contentType] of [
// 이름·MIME 은 PNG 인데 내용은 HTML
[
Buffer.from('<html><script>alert(1)</script></html>'),
'logo.png',
'image/png',
],
// 이름과 MIME 이 다르다
[pngBytes(10, 10), 'logo.png', 'image/gif'],
[pngBytes(10, 10), 'logo.webp', 'image/webp'],
[Buffer.from('<svg onload="x()"/>'), 'logo.svg', 'image/svg+xml'],
] as const) {
const res = await uploadAs(user, data, filename, contentType).expect(
400,
);
expect(body(res).error?.code).toBe('FILE_001');
}
const tooLarge = await uploadAs(
user,
pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1),
).expect(413);
expect(body(tooLarge).error?.code).toBe('FILE_002');
const missing = await request(server)
.post('/api/mail-signature/images')
.set('Cookie', cookieOf(user))
.expect(400);
expect(body(missing).error?.code).toBe('VAL_001');
// 하나도 저장되지 않았다
const rows: { id: string }[] = await dataSource.query(
'SELECT id FROM mail_signature_images WHERE user_email = $1',
[user],
);
expect(rows).toEqual([]);
});
it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => {
const user = 'sign.many@e2e.arasystem.test';
const ids: string[] = [];
for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) {
ids.push(await uploaded(user));
}
const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400);
expect(body(overflow).error?.code).toBe('VAL_001');
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
const tooMany = await saveSignature(user, {
bodyHtml: ids
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1)
.map(img)
.join(''),
}).expect(400);
expect(body(tooMany).error?.code).toBe('VAL_001');
const fits = await saveSignature(user, {
bodyHtml: ids
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)
.map(img)
.join(''),
}).expect(200);
expect(
dataOf<{ images: { id: string }[] }>(fits).images.map(
(image) => image.id,
),
).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE));
});
it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => {
const user = 'sign.save@e2e.arasystem.test';
const [a, b] = [await uploaded(user), await uploaded(user)];
const first = await saveSignature(user, {
bodyHtml: `<p onclick="x()">김아라</p><img src="${imageUrl(a)}" width="144" alt=""><img src="${imageUrl(b)}" width="96"><img src="${imageUrl('11111111-1111-4111-8111-111111111111')}">`,
body: '김아라 · 아라시스템',
isForReply: false,
}).expect(200);
expect(dataOf<Record<string, unknown>>(first)).toMatchObject({
bodyHtml: `<p>김아라</p><img src="${imageUrl(a)}" width="144" alt="" /><img src="${imageUrl(b)}" width="96" />`,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
images: [
expect.objectContaining({ id: a }) as unknown,
expect.objectContaining({ id: b }) as unknown,
],
updatedAt: expect.any(String) as string,
});
// b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다
const c = await uploaded(user);
const second = await saveSignature(user, {
bodyHtml: `<img src="${imageUrl(a)}" width="144">`,
body: null,
}).expect(200);
expect(
dataOf<{ images: { id: string }[] }>(second).images.map(
(image) => image.id,
),
).toEqual([a]);
const statusOf = async (id: string) =>
(
await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user))
).status;
expect(await statusOf(a)).toBe(200);
expect(await statusOf(b)).toBe(404);
expect(await statusOf(c)).toBe(200);
// 지운 그림은 파일도 없다
expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false);
const current = await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf(user))
.expect(200);
expect(dataOf<unknown>(current)).toEqual(dataOf<unknown>(second));
});
it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => {
const user = 'sign.limits@e2e.arasystem.test';
for (const payload of [
{ body: '가'.repeat(2_001) },
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
]) {
const res = await saveSignature(user, payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => {
const user = 'sign.body@e2e.arasystem.test';
const ids: string[] = [];
for (
let index = 0;
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
index += 1
) {
ids.push(await uploaded(user, pngBytes(40, 10)));
}
const bodyHtml = `<p>본문</p>${ids.map((id) => `<img src="${imageUrl(id)}" width="96">`).join('')}`;
const sentBefore = sentMessages.length;
const draftsBefore = drafts.size;
const send = await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(user))
.send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml })
.expect(400);
expect(body(send).error?.code).toBe('VAL_001');
const draft = await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(user))
.send({ to: [], subject: '그림 넷', body: '본문', bodyHtml })
.expect(400);
expect(body(draft).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(sentBefore);
expect(drafts.size).toBe(draftsBefore);
});
it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => {
const user = 'sign.race@e2e.arasystem.test';
const [a, b] = [await uploaded(user), await uploaded(user)];
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect(
200,
);
// 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다
const [first, second] = await Promise.all([
saveSignature(user, { bodyHtml: `<p>탭 1</p>${img(a)}` }),
saveSignature(user, { bodyHtml: `<p>탭 2</p>${img(b)}` }),
]);
expect([first.status, second.status]).toEqual([200, 200]);
const final = dataOf<{
bodyHtml: string | null;
images: { id: string }[];
}>(
await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf(user))
.expect(200),
);
const referenced = [
...(final.bodyHtml ?? '').matchAll(
/\/api\/mail-signature\/images\/([0-9a-f-]{36})/g,
),
].map((match) => match[1]);
expect(final.images.map((image) => image.id)).toEqual(referenced);
// 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다
for (const id of [a, b]) {
const res = await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user));
expect(res.status).toBe(referenced.includes(id) ? 200 : 404);
}
});
it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => {
const user = 'sign.cleanup@e2e.arasystem.test';
const [attached, loose] = [await uploaded(user), await uploaded(user)];
await saveSignature(user, {
bodyHtml: `<img src="${imageUrl(attached)}" width="96">`,
}).expect(200);
// 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다
const repository = app.get(MailSignatureRepository, { strict: false });
const deleted = await repository.deleteUnattachedImages([
attached,
loose,
]);
// 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게)
const left: { id: string }[] = await dataSource.query(
'SELECT id FROM mail_signature_images WHERE id = ANY($1)',
[[attached, loose]],
);
for (const id of [attached, loose]) {
const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`);
if (!left.some((row) => row.id === id) && existsSync(stored)) {
unlinkSync(stored);
}
}
expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]);
const statusOf = async (id: string) =>
(
await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user))
).status;
expect(await statusOf(attached)).toBe(200);
expect(await statusOf(loose)).toBe(404);
});
it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => {
const user = 'sign.send@e2e.arasystem.test';
const data = pngBytes(200, 50, 1_000);
const id = await uploaded(user, data);
const html = `<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고">`;
const cid = `sig-${id}@e2e.arasystem.test`;
await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(user))
.send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html })
.expect(201);
const sent = sentMessages[sentMessages.length - 1];
expect(sent.user).toBe(user);
expect(sent.message.htmlBody).toBe(
`<p>본문</p><img src="cid:${cid}" width="144" alt="로고" />`,
);
expect(sent.message.inlineImages).toEqual([
{ cid, type: 'image/png', name: 'signature-1.png', data },
]);
const saved = dataOf<SavedDraft>(
await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(user))
.send({ to: [], subject: '', body: '본문', bodyHtml: html })
.expect(201),
);
expect(drafts.get(saved.id)?.content.htmlBody).toBe(
`<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고" />`,
);
});
});
describe('받는 사람 추천 (/api/recipient-suggestions)', () => {
const ME = 'suggest.me@e2e.arasystem.test';
const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test';
const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test';
const suggest = (params: Record<string, string | number>) =>
request(server)
.get('/api/recipient-suggestions')
.query(params)
.set('Cookie', cookieOf(ME));
interface SuggestionPage {
items: { name: string | null; email: string; source: string }[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number;
};
}
beforeAll(async () => {
// 사내 목록은 로그인에 성공한 직원으로 만든다
await login(COLLEAGUE_A);
await login(COLLEAGUE_B);
const now = new Date().toISOString();
correspondents.set(ME, [
{ name: '박서준', email: COLLEAGUE_B, at: now },
{
name: '협력사',
email: 'partner@vendor.e2e.arasystem.test',
at: now,
},
{ name: null, email: 'no-reply@notice.e2e.arasystem.test', at: now },
{ name: null, email: 'mail-gateway@e2e.arasystem.test', at: now },
{ name: '나', email: ME, at: now },
]);
});
it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
// 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다
const res = await suggest({
q: 'e2e.arasystem.test',
limit: 100,
}).expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
const { items } = dataOf<SuggestionPage>(res);
const emails = items.map((item) => item.email);
expect(items).toContainEqual({
name: '가나다',
email: COLLEAGUE_A,
source: 'directory',
});
// 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만
expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan(
emails.indexOf(COLLEAGUE_A),
);
expect(emails.filter((email) => email === COLLEAGUE_B)).toHaveLength(1);
expect(items[items.length - 1]).toEqual({
name: '협력사',
email: 'partner@vendor.e2e.arasystem.test',
source: 'recent',
});
expect(emails).not.toContain(ME);
expect(emails).not.toContain('mail-gateway@e2e.arasystem.test');
expect(emails).not.toContain('no-reply@notice.e2e.arasystem.test');
});
it('q 와 커서 페이지네이션 — 다른 q 의 커서 · 틀린 커서 · 100자 넘는 q · limit 범위 밖은 400 VAL_001', async () => {
const first = dataOf<SuggestionPage>(
await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200),
);
expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
const cursor = first.pageInfo.nextCursor ?? '';
const second = dataOf<SuggestionPage>(
await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200),
);
expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]);
expect(second.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 2,
});
const rejected: Record<string, string | number>[] = [
{ q: 'partner', cursor },
{ cursor: 'not-a-cursor' },
{ q: 'x'.repeat(101) },
{ limit: 101 },
];
for (const params of rejected) {
const res = await suggest(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => {
const now = new Date().toISOString();
correspondents.set(ME, [
{
name: '새 거래처',
email: 'new@client.e2e.arasystem.test',
at: now,
},
]);
const emailsOf = async () =>
dataOf<SuggestionPage>(
await suggest({ q: 'client.e2e', limit: 100 }).expect(200),
).items.map((item) => item.email);
expect(await emailsOf()).toEqual([]);
await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '제목', body: '본문' })
.expect(201);
expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']);
});
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 서명 그림 테스트용 그림 파일 머리 — 테스트 전용 도우미 (앱 코드에서 import 하지 않는다).
*
* 형식 확인(signature-image-format.ts)은 파일 앞부분만 본다 — 여기서는 그 앞부분과, 크기를 맞출 채움 바이트만 만든다
* (실제로 그릴 수 있는 그림일 필요는 없다).
*
* 위치: src 밖(test/) — cpu-time.ts 와 같은 이유로 빌드·커버리지에 들어가지 않는다.
* 단위 테스트는 `@/../test/image-fixtures`, e2e 는 `./image-fixtures` 로 부른다.
*/
/** PNG 서명 + IHDR(가로·세로·비트 깊이·색 형식) — CRC 는 보지 않으므로 비워 둔다. `size` 바이트가 되게 0 으로 채운다 */
export function pngBytes(width: number, height: number, size = 33): Buffer {
const data = Buffer.alloc(Math.max(size, 33));
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data);
data.writeUInt32BE(13, 8);
data.write('IHDR', 12, 'ascii');
data.writeUInt32BE(width, 16);
data.writeUInt32BE(height, 20);
data[24] = 8; // 비트 깊이
data[25] = 6; // 색 형식 RGBA
return data;
}
/** GIF 머리 (논리 화면의 가로·세로, 리틀 엔디언) */
export function gifBytes(
width: number,
height: number,
version = 'GIF89a',
): Buffer {
const data = Buffer.alloc(13);
data.write(version, 0, 'ascii');
data.writeUInt16LE(width, 6);
data.writeUInt16LE(height, 8);
return data;
}
/** SOI + APP0(JFIF) + (선택) 표지 앞 채움 바이트 + SOFn(세로·가로) + EOI */
export function jpegBytes(
width: number,
height: number,
options: { sof?: number; fill?: boolean } = {},
): Buffer {
const app0 = Buffer.from([
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
]);
const sof = Buffer.from([
0xff,
options.sof ?? 0xc0,
0x00,
0x11,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x03,
0x01,
0x22,
0x00,
0x02,
0x11,
0x01,
0x03,
0x11,
0x01,
]);
return Buffer.concat([
Buffer.from([0xff, 0xd8]),
app0,
...(options.fill ? [Buffer.from([0xff, 0xff])] : []),
sof,
Buffer.from([0xff, 0xd9]),
]);
}
@@ -19,6 +19,7 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -19,6 +19,7 @@ export const ERROR_CODE_MESSAGES = {
MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES