feat: 메일 16단계 — 주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식과 회사 밖 응답 · 2단계 인증 휴대폰 바꾸기 #24

Merged
ttipo merged 5 commits from feat/personal-settings into main 2026-11-10 12:40:35 +09:00
63 changed files with 8282 additions and 464 deletions
Showing only changes of commit 0c3238936a - Show all commits
@@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계).
* mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다
* (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts).
* - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정)
* - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음.
* 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다)
* - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스
* - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다
* 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**.
* 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다.
* (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다)
*/
export class MultipleMailSignatures1794269810995 implements MigrationInterface {
name = 'MultipleMailSignatures1794269810995';
public async up(queryRunner: QueryRunner): Promise<void> {
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다)
await queryRunner.query(
`DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`UPDATE "mail_signatures" SET "created_at" = "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`,
);
// 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로)
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`,
);
// 열쇠 — 주소 → 서명 id
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
await queryRunner.query(
`DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_mail_signatures_new_message_default"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_mail_signatures_reply_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`,
);
// 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true)
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`,
);
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`);
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`);
}
}
@@ -43,6 +43,14 @@ export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
*/
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
/**
* 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다:
* - replaced: 바꿨다 — 옛 비밀은 바로 죽는다
* - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
* - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다
*/
export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown';
export interface AccountSecurityProvider {
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
isTwoFactorEnabled(user: string): Promise<boolean>;
@@ -64,6 +72,17 @@ export interface AccountSecurityProvider {
otpCode: string,
): Promise<TwoFactorDisableResult>;
/**
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
*/
replaceTwoFactor(
user: string,
currentPassword: string,
otpCode: string,
otpauthUrl: string,
): Promise<TwoFactorReplaceResult>;
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
@@ -101,6 +101,53 @@ export class TwoFactorActivationDto {
currentPassword!: string;
}
/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */
export class TwoFactorVerificationDto {
@ApiProperty({
description:
'새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)',
example: '123456',
})
@OtpCodeField()
otpCode!: string;
}
/** POST /api/two-factor/enrollments/{id}/verification → 200 */
export class TwoFactorVerifiedDto {
@ApiProperty({
description:
'늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)',
example: true,
})
verified!: true;
}
/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */
export class TwoFactorReplacementDto {
@ApiProperty({
description:
'확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)',
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
})
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
enrollmentId!: string;
@ApiProperty({
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
example: '654321',
})
@OtpCodeField()
currentOtpCode!: string;
@ApiProperty({
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
format: 'password',
example: 'current-password',
})
@CurrentPasswordField()
currentPassword!: string;
}
/** POST /api/two-factor/deactivation */
export class TwoFactorDeactivationDto {
@ApiProperty({
@@ -0,0 +1,21 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { isUUID } from 'class-validator';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001.
* 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008)
*/
@Injectable()
export class TwoFactorEnrollmentIdPipe implements PipeTransform<
unknown,
string
> {
transform(value: unknown): string {
if (typeof value === 'string' && isUUID(value, 'all')) return value;
throw new BusinessException('VAL_001', {
context: { reason: 'malformed-two-factor-enrollment-id' },
});
}
}
@@ -12,13 +12,16 @@ export interface TwoFactorEnrollment {
readonly secret: Buffer;
/** 끝나는 때 (epoch ms) */
readonly expiresAt: number;
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
readonly verified?: boolean;
}
/**
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
* 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
*
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다.
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
*/
@Injectable()
export class TwoFactorEnrollments {
@@ -45,6 +48,14 @@ export class TwoFactorEnrollments {
return enrollment.id === id ? enrollment : null;
}
/** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */
markVerified(email: string, id: string, now: number = Date.now()): boolean {
const enrollment = this.find(email, id, now);
if (!enrollment) return false;
this.pending.set(email, { ...enrollment, verified: true });
return true;
}
/** 등록을 버린다 — 비밀 바이트도 지운다 */
forget(email: string): void {
const enrollment = this.pending.get(email);
@@ -5,6 +5,7 @@ import {
HttpCode,
HttpStatus,
Ip,
Param,
Post,
UseGuards,
} from '@nestjs/common';
@@ -25,8 +26,12 @@ import {
TwoFactorActivationDto,
TwoFactorDeactivationDto,
TwoFactorEnrollmentDto,
TwoFactorReplacementDto,
TwoFactorStatusDto,
TwoFactorVerificationDto,
TwoFactorVerifiedDto,
} from './dto/two-factor.dto';
import { TwoFactorEnrollmentIdPipe } from './two-factor-enrollment-id.pipe';
import {
TwoFactorService,
type TwoFactorStatusView,
@@ -50,7 +55,7 @@ function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
'AUTH_001',
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
)
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010
@Controller('two-factor')
export class TwoFactorController {
constructor(private readonly twoFactor: TwoFactorService) {}
@@ -80,18 +85,19 @@ export class TwoFactorController {
@AllowRestrictedSession()
@NoStore()
@ApiOperation({
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
summary:
'2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)',
description:
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
'16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' +
'응답은 Cache-Control: private, no-store.',
})
@ApiStandardResponse(TwoFactorEnrollmentDto, {
status: 201,
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
})
@ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다')
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음')
async enroll(
@CurrentUser() user: AuthenticatedUser,
): Promise<TwoFactorEnrollmentDto> {
@@ -104,6 +110,93 @@ export class TwoFactorController {
};
}
@Post('enrollments/:id/verification')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '휴대폰 바꾸기 ① — 새 휴대폰의 코드 확인 (16단계)',
description:
'새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 30초) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않는다. 엔진에는 아직 아무것도 바꾸지 않는다. ' +
'받은 코드는 2분 동안 다시 받지 않는다(코드 실패 한도 · 같은 코드 막기는 켜기 · 끄기와 같다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다. ' +
'제한된 세션은 403 AUTH_010.',
})
@ApiStandardResponse(TwoFactorVerifiedDto, { description: '확인했다' })
@ApiErrorResponse(
'AUTH_006',
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 코드 · 등록 id 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'AUTH_008',
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 — 화면은 처음부터 다시(새 등록)',
)
@ApiErrorResponse(
'AUTH_010',
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
)
@ApiErrorResponse(
'REQ_001',
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 처리 중',
)
async verifyEnrollment(
@CurrentUser() user: AuthenticatedUser,
@Param('id', TwoFactorEnrollmentIdPipe) id: string,
@Body() dto: TwoFactorVerificationDto,
@Ip() ip: string,
): Promise<TwoFactorVerifiedDto> {
await this.twoFactor.verifyEnrollment(
user.email,
{ enrollmentId: id, otpCode: dto.otpCode },
{ ip },
);
return { verified: true };
}
@Post('replacements')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'휴대폰 바꾸기 ② — 지금 휴대폰의 코드와 지금 비밀번호로 바꾼다 (16단계)',
description:
'①에서 확인한 등록만 받는다. 지금 비밀번호를 먼저 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, 그사이 2단계가 꺼졌으면 AUTH_008) ' +
'지금 휴대폰의 코드와 새 비밀을 엔진 요청 하나로 보낸다 — 2단계가 꺼지는 틈이 없고 옛 휴대폰의 코드는 바로 막힌다. ' +
'지금 코드가 틀리거나 2분 안에 다시 쓴 코드면 AUTH_006(코드 실패로 센다 — 등록은 남아 다시 보낼 수 있다). ' +
'결과를 모르면 503 MAIL_001 — 엔진이 비밀을 가려 보여 주어 다시 읽어도 가를 수 없다(등록은 남는다). 켠 때(enabledAt)는 그대로, 로그인된 기기는 끊지 않는다. ' +
'제한된 세션은 403 AUTH_010.',
})
@ApiStandardResponse(TwoFactorStatusDto, { description: '바꿨다' })
@ApiErrorResponse(
'AUTH_006',
'AUTH_006 지금 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'AUTH_008',
'등록이 없음 · 10분 지남 · 더 새 등록 · 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 — 화면은 처음부터 다시',
)
@ApiErrorResponse(
'AUTH_010',
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
)
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 결과를 알 수 없음(가를 수 없다 — 다시 보내기 전에 새 휴대폰의 코드로 로그인되는지 본다)',
)
async replace(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: TwoFactorReplacementDto,
@Ip() ip: string,
): Promise<TwoFactorStatusDto> {
const status = await this.twoFactor.replace(
user.email,
{
enrollmentId: dto.enrollmentId,
currentOtpCode: dto.currentOtpCode,
currentPassword: dto.currentPassword,
},
{ ip },
);
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
}
@Post('activation')
@AllowRestrictedSession()
@HttpCode(HttpStatus.OK)
@@ -16,6 +16,7 @@ import type {
AccountSecurityProvider,
TwoFactorDisableResult,
TwoFactorEnableResult,
TwoFactorReplaceResult,
} from './account-security.provider';
import { TwoFactorCodeGuard } from './two-factor-code-guard';
import { TwoFactorEnrollments } from './two-factor-enrollments';
@@ -114,7 +115,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
let provider: jest.Mocked<
Pick<
AccountSecurityProvider,
'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor'
| 'isTwoFactorEnabled'
| 'enableTwoFactor'
| 'disableTwoFactor'
| 'replaceTwoFactor'
>
>;
let mail: { verifyCredentials: jest.Mock };
@@ -160,6 +164,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
engineEnabled = false;
return Promise.resolve('disabled');
}),
replaceTwoFactor: jest.fn<
Promise<TwoFactorReplaceResult>,
[string, string, string, string]
>(() => Promise.resolve('replaced')),
};
mail = {
verifyCredentials: jest.fn(() =>
@@ -376,7 +384,7 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
).toBe('AUTH_008');
});
it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => {
it('그사이 다른 곳에서 켜졌으면 켜기는 409 AUTH_008 — 등록은 켜져 있어도 받는다(16단계 휴대폰 바꾸기용 · 엔진에 묻지 않는다)', async () => {
const { enrollment, code } = await enroll();
engineEnabled = true;
@@ -395,9 +403,11 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
)
)?.code,
).toBe('AUTH_008');
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
'AUTH_008',
);
provider.isTwoFactorEnabled.mockClear();
await expect(service.startEnrollment(ARA)).resolves.toMatchObject({
secret: expect.stringMatching(/^[A-Z2-7]{32}$/) as unknown,
});
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
});
it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => {
@@ -579,6 +589,251 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
}
});
describe('휴대폰 바꾸기 (16단계 — 켠 채 새 비밀로)', () => {
const OLD_CODE = '135790';
const enabledAt = new Date('2026-10-31T00:12:00.000Z');
beforeEach(() => {
engineEnabled = true;
table.rows.set(ARA, { userEmail: ARA, enabledAt });
// 엔진 대역 — 지금 휴대폰의 코드는 OLD_CODE 하나만 맞다
provider.replaceTwoFactor.mockImplementation((_user, _password, code) =>
Promise.resolve(code === OLD_CODE ? 'replaced' : 'code-rejected'),
);
});
/** 등록하고 새 휴대폰의 코드로 확인한다 */
const verified = async () => {
const { enrollment, code } = await enroll();
const newCode = code();
await service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: newCode },
{ ip: IP },
);
return { enrollment, newCode, code };
};
const replace = (
enrollmentId: string,
overrides: { currentOtpCode?: string; currentPassword?: string } = {},
ip = IP,
) =>
service.replace(
ARA,
{
enrollmentId,
currentOtpCode: OLD_CODE,
currentPassword: PASSWORD,
...overrides,
},
{ ip },
);
it('① 새 휴대폰의 코드를 등록의 비밀로 확인(엔진에 묻지 않는다) → ② 지금 비밀번호(본인 Basic 402) → 지금 코드와 새 주소를 엔진 한 요청 — 켠 때는 그대로, 등록은 지운다', async () => {
const { enrollment, newCode } = await verified();
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
expect(codes.isUsed(ARA, newCode)).toBe(true);
const status = await replace(enrollment.enrollmentId);
expect(status).toEqual({ isEnabled: true, enabledAt });
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
expect(provider.replaceTwoFactor).toHaveBeenCalledWith(
ARA,
PASSWORD,
OLD_CODE,
enrollment.otpauthUrl,
);
expect(codes.isUsed(ARA, OLD_CODE)).toBe(true);
expect(table.rows.get(ARA)?.enabledAt).toEqual(enabledAt);
expect(logs).toContain(
'2단계 인증 휴대폰을 바꿨습니다: a***@arasystem.test',
);
expect(logs.join('\n')).not.toMatch(
new RegExp(`${OLD_CODE}|${newCode}|${PASSWORD}|otpauth`),
);
// 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('확인하지 않은 등록은 409 AUTH_008 — 등록을 버린다(처음부터) · 없는 등록 · 다른 id 도 AUTH_008', async () => {
const { enrollment, code } = await enroll();
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
// 버렸다 — 확인도 할 수 없다
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('AUTH_008');
expect(
(await failureOf(replace('00000000-0000-4000-8000-000000000000')))
?.code,
).toBe('AUTH_008');
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
});
it('① 새 휴대폰의 코드가 틀리면 400 AUTH_006 — 코드 실패로 센다(15분 5회 → REQ_001), 확인되지 않는다', async () => {
const { enrollment, code } = await enroll();
const wrong = code() === '000000' ? '111111' : '000000';
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: wrong },
{ ip: `198.51.100.${i}` },
),
)
)?.code,
).toBe('AUTH_006');
}
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('REQ_001');
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('② 지금 비밀번호가 틀리면 400 AUTH_005(로그인 실패로 센다) · 앱 비밀번호 모양은 묻지 않고 AUTH_005 — 엔진에 보내지 않고 등록은 남는다', async () => {
const { enrollment } = await verified();
mail.verifyCredentials.mockResolvedValueOnce(INVALID);
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentPassword: 'wrong' }),
)
)?.code,
).toBe('AUTH_005');
mail.verifyCredentials.mockClear();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, {
currentPassword: `app_${'a'.repeat(36)}`,
}),
)
)?.context,
).toMatchObject({ reason: 'app-password-shape' });
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
// 등록은 남았다 — 비밀번호만 고쳐 다시 보낸다
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('② 그사이 다른 곳에서 꺼졌으면(본인 Basic 200) 409 AUTH_008 — 엔진에 보내지 않고 등록을 버린다', async () => {
const { enrollment } = await verified();
engineEnabled = false;
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
engineEnabled = true;
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('② 엔진이 지금 코드를 받지 않으면(forbidden) 400 AUTH_006 — 코드 실패로 센다, 등록은 남아 맞는 코드로 다시 보낼 수 있다', async () => {
const { enrollment, newCode } = await verified();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentOtpCode: '246800' }),
)
)?.code,
).toBe('AUTH_006');
// 다시 쓴 코드(①에서 쓴 새 휴대폰의 코드)는 엔진에 묻지 않고 AUTH_006
provider.replaceTwoFactor.mockClear();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentOtpCode: newCode }),
)
)?.code,
).toBe('AUTH_006');
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('② 결과를 모르면 503 MAIL_001 — 다시 읽지 않는다(가를 수 없다), 코드는 쓰지 않은 것으로, 등록은 남는다', async () => {
const { enrollment } = await verified();
provider.replaceTwoFactor.mockResolvedValueOnce('unknown');
provider.isTwoFactorEnabled.mockClear();
const failure = await failureOf(replace(enrollment.enrollmentId));
expect(failure?.code).toBe('MAIL_001');
expect(failure?.context).toMatchObject({
reason: 'two-factor-replace-outcome-unknown',
});
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
expect(codes.isUsed(ARA, OLD_CODE)).toBe(false);
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('같은 직원의 바꾸기가 처리 중이면 확인 · 바꾸기 모두 429 REQ_001', async () => {
const { enrollment, code } = await enroll();
const release = locks.acquire(ARA);
try {
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('REQ_001');
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'REQ_001',
);
} finally {
release();
}
});
it('켠 때 기록을 읽지 못해도 바꾸기는 성공 — enabledAt null · error 로그', async () => {
const { enrollment } = await verified();
table.repository.findOneBy.mockRejectedValueOnce(new Error('db down'));
await expect(replace(enrollment.enrollmentId)).resolves.toEqual({
isEnabled: true,
enabledAt: null,
});
expect(errors.join('\n')).toContain('켠 때 기록을 읽지 못했습니다');
});
});
describe('끄기', () => {
beforeEach(() => {
engineEnabled = true;
@@ -31,6 +31,7 @@ import { TwoFactorCodeGuard } from './two-factor-code-guard';
import {
TwoFactorEnrollments,
enrollmentExpiry,
type TwoFactorEnrollment,
} from './two-factor-enrollments';
import { TwoFactorLocks } from './two-factor-locks';
import { TwoFactorPolicyService } from './two-factor-policy.service';
@@ -74,7 +75,9 @@ function errorMessage(error: unknown): string {
/**
* 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다).
*
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 켜져 있어도 받는다 — 휴대폰 바꾸기용(16단계)
* - 휴대폰 바꾸기(16단계): ① 새 휴대폰의 코드를 등록의 비밀로 확인해 등록에 "확인됨"을 단다(verifyEnrollment) ② 지금 비밀번호(본인 Basic 402) →
* 지금 휴대폰의 코드와 새 otpauth 주소를 엔진 한 요청으로(replace — 2단계가 꺼지는 틈이 없다). 결과를 모르면 MAIL_001(다시 읽어도 가를 수 없다)
* - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다).
* 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다,
* 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다.
@@ -134,18 +137,13 @@ export class TwoFactorService {
return this.reconcile(address, enabled);
}
/** POST /api/two-factor/enrollments — 꺼져 있을 때만 */
/**
* POST /api/two-factor/enrollments — 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용(16단계) — 같은 등록이다.
* 엔진에 묻지 않는다: 켜기는 비밀번호 확인(402)이, 바꾸기는 엔진이 지금 코드로 가른다
*/
async startEnrollment(user: string): Promise<TwoFactorEnrollmentView> {
const address = this.requireEmployee(user);
return this.locks.run(address, async () => {
if (await this.provider.isTwoFactorEnabled(address)) {
throw new BusinessException('AUTH_008', {
context: {
reason: 'two-factor-already-enabled',
email: maskEmail(address),
},
});
}
return this.locks.run(address, () => {
const now = Date.now();
const enrollment = {
id: randomUUID(),
@@ -154,15 +152,181 @@ export class TwoFactorService {
};
this.enrollments.put(address, enrollment);
const secret = encodeBase32(enrollment.secret);
return {
return Promise.resolve({
enrollmentId: enrollment.id,
secret,
otpauthUrl: this.otpauthUrl(address, secret),
expiresAt: new Date(enrollment.expiresAt),
};
});
});
}
/**
* POST /api/two-factor/enrollments/{id}/verification — 새 휴대폰의 코드를 등록의 비밀로 확인하고 등록에 "확인됨"을 단다 (16단계 휴대폰 바꾸기 ①).
* 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 코드 확인은 켜기 · 끄기와 같은 코드 가드(실패 한도 · 2분 다시 쓰기 막기 — 받은 코드는 기억한다).
* 등록이 없음 · 10분 지남 · 더 새 등록이면 AUTH_008, 코드 틀림 AUTH_006. 엔진에 묻지 않는다
*/
async verifyEnrollment(
user: string,
input: { readonly enrollmentId: string; readonly otpCode: string },
client: SecurityClient,
): Promise<void> {
const address = this.requireEmployee(user);
const ip = client.ip || 'unknown';
return this.locks.run(address, async () => {
const enrollment = this.requireEnrollment(address, input.enrollmentId);
await this.codes.check<undefined>(
{ email: address, ip },
input.otpCode,
() =>
Promise.resolve(
verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null
? { kind: 'rejected' as const }
: { kind: 'accepted' as const, value: undefined },
),
);
if (!this.enrollments.markVerified(address, input.enrollmentId)) {
// 코드를 보는 사이 10분이 지났다
throw this.enrollmentMissing(address, 'two-factor-enrollment-expired');
}
});
}
/**
* POST /api/two-factor/replacements — 2단계 인증을 켠 채 휴대폰을 바꾼다 (16단계 ②). 확인한 등록 · 지금 휴대폰의 코드 · 지금 비밀번호.
* 1. 등록이 확인됨이 아니면 AUTH_008(등록을 버린다 — 처음부터)
* 2. 지금 비밀번호를 본인 Basic 으로 — 402 여야 한다(켜짐 · 비밀번호 맞음). 401 · 앱 비밀번호 모양은 AUTH_005(로그인 실패 한도로 센다 — 모양은 세지 않는다),
* 200 이면 그사이 다른 곳에서 꺼졌다 → AUTH_008(등록을 버린다). 엔진은 비밀번호 틀림과 코드 틀림을 가르지 않아 비밀번호를 먼저 본다
* 3. 다시 쓴 코드 · 코드 실패 차지(끄기와 같은 결) → 엔진 한 요청(지금 코드 + 새 otpauth 주소). forbidden 은 지금 코드 틀림 AUTH_006,
* 결과를 모르면 MAIL_001(엔진은 otpUrl 을 가려 다시 읽어도 가를 수 없다 — 그 코드는 쓰지 않은 것으로, 등록은 남긴다)
* 4. 바꾸면 등록의 비밀을 지운다. 켠 때(enabledAt)는 그대로. 로그인된 기기는 끊지 않는다(켜기 · 끄기와 같다)
*/
async replace(
user: string,
input: {
readonly enrollmentId: string;
readonly currentOtpCode: string;
readonly currentPassword: string;
},
client: SecurityClient,
): Promise<TwoFactorStatus> {
const address = this.requireEmployee(user);
const ip = client.ip || 'unknown';
return this.locks.run(address, async () => {
const enrollment = this.requireEnrollment(address, input.enrollmentId);
if (enrollment.verified !== true) {
this.enrollments.forget(address);
throw this.enrollmentMissing(
address,
'two-factor-enrollment-unverified',
);
}
if (isAppPasswordShape(input.currentPassword)) {
throw this.currentPasswordRejected(address, 'app-password-shape');
}
const otpauthUrl = this.otpauthUrl(
address,
encodeBase32(enrollment.secret),
);
const ticket = this.attempts.begin({ email: address, ip });
try {
const check = await this.mail.verifyCredentials(
address,
input.currentPassword,
);
if (check.kind === 'invalid') {
ticket.fail();
throw this.currentPasswordRejected(
address,
'current-password-rejected',
);
}
if (check.kind === 'valid') {
// 엔진에서 그사이 꺼졌다 — 바꿀 것이 없다. 처음부터(켜기)
ticket.succeed();
this.enrollments.forget(address);
throw new BusinessException('AUTH_008', {
context: {
reason: 'two-factor-disabled-elsewhere',
email: maskEmail(address),
},
});
}
// 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
ticket.verified();
await this.codes.check<undefined>(
{ email: address, ip },
input.currentOtpCode,
async () => {
const outcome = await this.provider.replaceTwoFactor(
address,
input.currentPassword,
input.currentOtpCode,
otpauthUrl,
);
if (outcome === 'code-rejected')
return { kind: 'rejected' as const };
if (outcome === 'unknown') {
throw new BusinessException('MAIL_001', {
context: {
reason: 'two-factor-replace-outcome-unknown',
email: maskEmail(address),
},
});
}
return { kind: 'accepted' as const, value: undefined };
},
);
ticket.succeed();
} finally {
ticket.abort();
}
// 엔진에서 바뀌었다 — 등록의 비밀을 지운다
this.enrollments.forget(address);
this.logger.log(`2단계 인증 휴대폰을 바꿨습니다: ${maskEmail(address)}`);
return { isEnabled: true, enabledAt: await this.readEnabledAt(address) };
});
}
/** 기다리는 등록 — 없음 · 10분 지남 · 더 새 등록이면 AUTH_008 */
private requireEnrollment(
address: string,
enrollmentId: string,
): TwoFactorEnrollment {
const enrollment = this.enrollments.find(address, enrollmentId);
if (!enrollment) {
throw this.enrollmentMissing(address, 'two-factor-enrollment-missing');
}
return enrollment;
}
private enrollmentMissing(
address: string,
reason: string,
): BusinessException {
return new BusinessException('AUTH_008', {
context: { reason, email: maskEmail(address) },
});
}
/** 켠 때 기록 — 읽지 못하면 error 로그만 남기고 null (엔진에서는 이미 바뀌었다) */
private async readEnabledAt(address: string): Promise<Date | null> {
try {
return (
(await this.settings.findOneBy({ userEmail: address }))?.enabledAt ??
null
);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 2단계 인증 휴대폰을 바꿨지만 켠 때 기록을 읽지 못했습니다(엔진에서는 바뀌었다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
);
return null;
}
}
/** POST /api/two-factor/activation */
async activate(
user: string,
+4 -1
View File
@@ -320,12 +320,14 @@ export function weightAfterChanges(
export interface WrittenChanges {
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
/** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */
readonly refusedKeys?: ReadonlySet<string>;
}
/**
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다.
* 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다.
* 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
* 엔진이 받지 않은 만들기(refusedKeys)는 건너뛴다. 그 밖의 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
*/
export function applyAddressBookWrite(
snapshot: AddressBookSnapshot,
@@ -352,6 +354,7 @@ export function applyAddressBookWrite(
.map((card) => applyGroupUpdate(card, updates.get(card.id)));
for (const card of changes.create) {
if (written.refusedKeys?.has(card.key)) continue;
const id = written.createdIds.get(card.key);
const read = id === undefined ? undefined : written.cards.get(id);
if (read === undefined) return null;
@@ -0,0 +1,291 @@
import {
contactExportContentType,
contactExportFileName,
contactsToCsv,
contactsToVcard,
CSV_HEADER,
foldVcardLine,
type ExportedContact,
} from './contact-export';
const contact = (
overrides: Partial<ExportedContact> = {},
): ExportedContact => ({
uid: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 담당',
groupNames: ['거래처'],
...overrides,
});
/** 접힌 줄을 푼다 (CRLF + 공백 하나를 지운다 — RFC 2425 5.8.1) */
const unfold = (text: string) => text.replace(/\r\n /g, '');
describe('내보내기 — vCard 3.0', () => {
it('한 사람 — 줄 끝 CRLF, VERSION 은 BEGIN 바로 다음, 우리 칸만 (계약 3절 "내보내기")', () => {
expect(contactsToVcard([contact()])).toBe(
[
'BEGIN:VCARD',
'VERSION:3.0',
'UID:urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
'FN:박지훈',
'N:박지훈;;;;',
'EMAIL;TYPE=INTERNET,WORK:jihoon.park@hanbit.example',
'TEL;TYPE=CELL:010-3192-5540',
'ORG:한빛상사;구매팀',
'TITLE:과장',
'NOTE:견적 담당',
'CATEGORIES:거래처',
'END:VCARD',
'',
].join('\r\n'),
);
});
it('종류 — 이메일 WORK · HOME · INTERNET, 전화 CELL · WORK · HOME · VOICE', () => {
const text = contactsToVcard([
contact({
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'b@x.example', label: 'personal' },
{ address: 'c@x.example', label: 'other' },
],
phones: [
{ number: '1', label: 'mobile' },
{ number: '2', label: 'work' },
{ number: '3', label: 'home' },
{ number: '4', label: 'other' },
],
}),
]);
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,WORK:a@x.example\r\n');
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,HOME:b@x.example\r\n');
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET:c@x.example\r\n');
expect(text).toContain(
'\r\nTEL;TYPE=CELL:1\r\nTEL;TYPE=WORK:2\r\nTEL;TYPE=HOME:3\r\nTEL;TYPE=VOICE:4\r\n',
);
});
it('이스케이프 — \\ ; , 줄바꿈 (그룹 이름 안의 쉼표도)', () => {
const text = contactsToVcard([
contact({
name: '김; 아라, 팀장',
company: 'A;B',
department: null,
note: '첫 줄\n둘째 줄 C:\\temp',
groupNames: ['가,나', '다'],
}),
]);
expect(text).toContain('\r\nFN:김\\; 아라\\, 팀장\r\n');
expect(text).toContain('\r\nN:김\\; 아라\\, 팀장;;;;\r\n');
expect(text).toContain('\r\nORG:A\\;B;\r\n');
expect(text).toContain('\r\nNOTE:첫 줄\\n둘째 줄 C:\\\\temp\r\n');
expect(text).toContain('\r\nCATEGORIES:가\\,나,다\r\n');
});
it('빈 칸 — 이름이 없으면 FN 은 첫 이메일(N 은 비운다), 없는 칸 · uid 는 줄을 넣지 않는다, 회사가 없고 부서만 있으면 ORG:;부서', () => {
const text = contactsToVcard([
contact({
uid: null,
name: null,
emails: [
{ address: 'first@x.example', label: 'work' },
{ address: 'second@x.example', label: 'other' },
],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: null,
department: '구매팀',
title: null,
note: null,
groupNames: [],
}),
]);
expect(text).toBe(
[
'BEGIN:VCARD',
'VERSION:3.0',
'FN:first@x.example',
'N:;;;;',
'EMAIL;TYPE=INTERNET,WORK:first@x.example',
'EMAIL;TYPE=INTERNET:second@x.example',
'TEL;TYPE=CELL:010-3192-5540',
'ORG:;구매팀',
'END:VCARD',
'',
].join('\r\n'),
);
});
it('이름 · 이메일이 없으면 FN 은 첫 전화번호, 그것도 없으면 빈 FN (N 은 늘 이름 칸 그대로)', () => {
const phoneOnly = contactsToVcard([
contact({
name: null,
emails: [],
phones: [
{ number: '02-555-0100', label: 'work' },
{ number: '010-3192-5540', label: 'mobile' },
],
}),
]);
expect(phoneOnly).toContain('\r\nFN:02-555-0100\r\nN:;;;;\r\n');
const nothing = contactsToVcard([
contact({ name: null, emails: [], phones: [] }),
]);
expect(nothing).toContain('\r\nFN:\r\nN:;;;;\r\n');
// 이름이 있으면 이름 — 주소 · 번호로 채우지 않는다
expect(contactsToVcard([contact()])).toContain('\r\nFN:박지훈\r\n');
});
it('줄 접기 — 75바이트마다(이어지는 줄은 공백 하나로 시작해 74바이트), 한글(3바이트)을 가르지 않는다', () => {
const line = `NOTE:${'가'.repeat(60)}`;
const folded = foldVcardLine(line);
const lines = folded.split('\r\n');
expect(lines.length).toBeGreaterThan(1);
for (const [index, part] of lines.entries()) {
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
if (index > 0) expect(part.startsWith(' ')).toBe(true);
// 가른 곳이 글자 사이다 — 바이트로 바꿔도 깨진 글자(U+FFFD)가 없다
expect(Buffer.from(part, 'utf8').toString('utf8')).not.toContain(
'\uFFFD',
);
}
expect(unfold(folded)).toBe(line);
// 75바이트 이하는 그대로
expect(foldVcardLine('A'.repeat(75))).toBe('A'.repeat(75));
expect(foldVcardLine('A'.repeat(76))).toBe(`${'A'.repeat(75)}\r\n A`);
// 이모지(4바이트 · 서로게이트 쌍)도 가르지 않는다
const emoji = foldVcardLine(`FN:${'😀'.repeat(30)}`);
expect(unfold(emoji)).toBe(`FN:${'😀'.repeat(30)}`);
for (const part of emoji.split('\r\n')) {
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
expect(/[\uD800-\uDBFF]$/.test(part)).toBe(false);
expect(/^ ?[\uDC00-\uDFFF]/.test(part)).toBe(false);
}
});
it('여러 명은 한 파일에 이어 붙인다 · 연락처가 없으면 빈 파일', () => {
const text = contactsToVcard([contact(), contact({ name: '김아라' })]);
expect(text.match(/BEGIN:VCARD/g)).toHaveLength(2);
expect(text.endsWith('END:VCARD\r\n')).toBe(true);
expect(contactsToVcard([])).toBe('');
});
});
describe('내보내기 — CSV', () => {
it('UTF-8 BOM · CRLF · 한국어 머리 — 같은 칸 여럿은 "; ", 이메일은 종류 없이 모두', () => {
const text = contactsToCsv([
contact({
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'b@x.example', label: 'personal' },
],
phones: [
{ number: '010-1111-2222', label: 'mobile' },
{ number: '010-3333-4444', label: 'mobile' },
{ number: '02-555-0100', label: 'work' },
{ number: '031-000-0000', label: 'home' },
{ number: '070-1234-5678', label: 'other' },
],
groupNames: ['거래처', '협력사'],
}),
]);
expect(text.startsWith('\uFEFF')).toBe(true);
expect(text).toBe(
'\uFEFF' +
'이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' +
'박지훈,a@x.example; b@x.example,010-1111-2222; 010-3333-4444,02-555-0100,031-000-0000,070-1234-5678,한빛상사,구매팀,과장,견적 담당,거래처; 협력사\r\n',
);
expect(CSV_HEADER).toHaveLength(11);
});
it("= + - @ 로 시작하는 칸은 앞에 ' (엑셀 수식 막기) · 쉼표 · 따옴표 · 줄바꿈은 따옴표로 감싼다", () => {
const text = contactsToCsv([
contact({
name: '=HYPERLINK("http://x")',
emails: [],
phones: [{ number: '+82 10-1234-5678', label: 'mobile' }],
company: '-주식회사',
department: '@영업',
title: '과장, 팀장',
note: '첫 줄\n둘째 줄',
groupNames: [],
}),
]);
const row = text.split('\r\n')[1];
expect(row).toBe(
`"'=HYPERLINK(""http://x"")",,'+82 10-1234-5678,,,,'-주식회사,'@영업,"과장, 팀장","첫 줄\n둘째 줄",`,
);
});
it("탭 · CR 로 시작하는 칸도 앞에 ' (OWASP CSV Injection) — CR 이 든 칸은 따옴표로 감싼다", () => {
const text = contactsToCsv([
contact({
name: '\t=1+1',
emails: [],
phones: [],
company: '\r=1+1',
department: null,
title: '가\t나',
note: null,
groupNames: [],
}),
]);
// 가운데의 탭은 그대로 — 첫 글자만 본다
expect(text.slice(text.indexOf('\r\n') + 2)).toBe(
`'\t=1+1,,,,,,"'\r=1+1",,가\t나,,\r\n`,
);
});
it('빈 칸은 비운다 · 연락처가 없으면 머리 줄만', () => {
const text = contactsToCsv([
contact({
name: null,
phones: [],
company: null,
department: null,
title: null,
note: null,
groupNames: [],
}),
]);
expect(text.split('\r\n')[1]).toBe(',jihoon.park@hanbit.example,,,,,,,,,');
expect(contactsToCsv([])).toBe(`\uFEFF${CSV_HEADER.join(',')}\r\n`);
});
});
describe('내보내기 — 파일 이름 · 형식', () => {
// 2026-11-10 15:30 UTC = 2026-11-11 00:30 KST
const at = new Date('2026-11-10T15:30:00.000Z');
it('주소록-YYYY-MM-DD(한국 날짜) · 그룹이면 주소록-<그룹 이름>-YYYY-MM-DD', () => {
expect(contactExportFileName('vcard', null, at)).toBe(
'주소록-2026-11-11.vcf',
);
expect(contactExportFileName('csv', '거래처', at)).toBe(
'주소록-거래처-2026-11-11.csv',
);
});
it('그룹 이름의 예약 글자는 _ · 앞뒤 공백과 점은 뗀다 · 남는 것이 없으면 그룹 칸을 빼고 · 50글자까지', () => {
expect(contactExportFileName('csv', ' 팀/파트:A* ', at)).toBe(
'주소록-팀_파트_A_-2026-11-11.csv',
);
expect(contactExportFileName('csv', ' .. ', at)).toBe(
'주소록-2026-11-11.csv',
);
expect(contactExportFileName('vcard', '가'.repeat(80), at)).toBe(
`주소록-${'가'.repeat(50)}-2026-11-11.vcf`,
);
});
it('Content-Type — text/vcard · text/csv (UTF-8)', () => {
expect(contactExportContentType('vcard')).toBe('text/vcard; charset=utf-8');
expect(contactExportContentType('csv')).toBe('text/csv; charset=utf-8');
});
});
@@ -0,0 +1,267 @@
import { kstDateString } from '@/shared/utils/kst-date';
import { MAX_CONTACT_GROUP_NAME_LENGTH } from './contacts.constants';
import type {
ContactEmailLabel,
ContactExportFormat,
ContactFields,
ContactPhoneLabel,
} from './contacts.types';
/**
* 주소록 내보내기 파일 (순수 함수) — 계약 3절 "내보내기" (16단계). 우리 칸만 쓴다(엔진 카드의 다른 칸은 읽지 않는다).
*
* vCard 3.0 (RFC 2426): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않게 — 이어지는 줄은 공백 하나로 시작) ·
* 글 값은 `\` `;` `,` 줄바꿈을 이스케이프. FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL · TEL · ORG(회사;부서) ·
* TITLE · NOTE · CATEGORIES(그룹 이름) · UID.
*
* CSV: UTF-8 BOM · CRLF · 한국어 머리(아래 CSV_HEADER) — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
* 칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180).
* 우리 가져오기(화면)가 이 두 모양을 다시 받는다 — 머리 이름 · 차례를 바꾸면 frontend shared/utils/contact-import 도 함께
*/
/** 내보낼 연락처 하나 — 우리 칸 + 카드의 uid · 속한 그룹 이름(그룹 이름 차례) */
export interface ExportedContact extends ContactFields {
readonly uid: string | null;
readonly groupNames: readonly string[];
}
export const VCARD_CONTENT_TYPE = 'text/vcard; charset=utf-8';
export const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
const CRLF = '\r\n';
/** vCard 한 줄의 바이트 상한 (줄 끝 제외 — 이어지는 줄은 앞의 공백까지 센다) */
const VCARD_LINE_OCTETS = 75;
const VCARD_EMAIL_TYPES: Record<ContactEmailLabel, string> = {
work: 'INTERNET,WORK',
personal: 'INTERNET,HOME',
other: 'INTERNET',
};
const VCARD_PHONE_TYPES: Record<ContactPhoneLabel, string> = {
mobile: 'CELL',
work: 'WORK',
home: 'HOME',
other: 'VOICE',
};
/** CSV 머리 — 칸 차례 (계약 3절 "내보내기") */
export const CSV_HEADER = [
'이름',
'이메일',
'휴대폰',
'회사 전화',
'집 전화',
'기타 전화',
'회사',
'부서',
'직함',
'메모',
'그룹',
] as const;
/** 같은 칸에 여럿을 잇는 글 */
const CSV_JOINER = '; ';
/** 엑셀이 수식으로 읽는 첫 글자(OWASP CSV Injection — `= + - @` · 탭 · CR) — 앞에 `'` 를 붙인다 */
const CSV_FORMULA_START = /^[=+\-@\t\r]/;
/** 따옴표로 감쌀 칸 */
const CSV_NEEDS_QUOTES = /[",\r\n]/;
/** UTF-8 BOM(U+FEFF) — 엑셀이 한글을 UTF-8 로 읽게 (소스에 보이지 않는 글자를 두지 않는다) */
const UTF8_BOM = String.fromCharCode(0xfeff);
/** 파일 이름 앞머리 · 형식마다 확장자 */
const FILE_NAME_PREFIX = '주소록';
const FILE_EXTENSIONS: Record<ContactExportFormat, string> = {
vcard: '.vcf',
csv: '.csv',
};
/** 파일 이름에 쓸 수 없는 글자(경로 구분자 · Windows 예약 글자) — '_' 로 */
const FILE_NAME_RESERVED = /[\\/:*?"<>|]/g;
/** 그룹 이름 앞뒤에서 떼는 글자 — 공백과 점 (앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
const FILE_NAME_EDGE = /^[\s.]$/u;
/** vCard 글 값의 이스케이프 — `\` `;` `,` 줄바꿈(\n) (한 번 훑는다) */
function escapeVcardText(value: string): string {
return value.replace(/[\\;,]|\r\n|\r|\n/g, (match) => {
if (match === '\\') return '\\\\';
if (match === ';') return '\\;';
if (match === ',') return '\\,';
return '\\n';
});
}
/** UTF-8 바이트 수 — 코드 포인트 하나 */
function utf8Length(codePoint: number): number {
if (codePoint < 0x80) return 1;
if (codePoint < 0x800) return 2;
if (codePoint < 0x10000) return 3;
return 4;
}
/** 한 줄을 75바이트마다 접는다 — 글자(코드 포인트)를 가르지 않는다. 이어지는 줄은 공백 하나로 시작한다 */
export function foldVcardLine(line: string): string {
const parts: string[] = [];
let current = '';
let bytes = 0;
let limit = VCARD_LINE_OCTETS;
for (const char of line) {
const size = utf8Length(char.codePointAt(0) ?? 0);
if (bytes + size > limit) {
parts.push(current);
current = '';
bytes = 0;
// 이어지는 줄 앞의 공백 하나도 75바이트에 든다
limit = VCARD_LINE_OCTETS - 1;
}
current += char;
bytes += size;
}
parts.push(current);
return parts.join(`${CRLF} `);
}
/**
* 보이는 이름(FN) — 이름, 없으면 첫 이메일, 그것도 없으면 첫 전화번호(vCard 3.0 은 FN 을 꼭 담는다 — 빈 FN 은 받는 쪽이 "이름 없음"으로 보인다).
* N 은 이름 칸 그대로 둔다(이름이 없으면 빈 칸 — 다시 가져올 때 주소를 이름으로 읽지 않게)
*/
function displayNameOf(contact: ExportedContact): string {
return (
contact.name ??
contact.emails[0]?.address ??
contact.phones[0]?.number ??
''
);
}
/** 연락처 하나의 vCard 3.0 */
function vcardOf(contact: ExportedContact): string {
const name = contact.name ?? '';
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
if (contact.uid !== null) lines.push(`UID:${escapeVcardText(contact.uid)}`);
lines.push(`FN:${escapeVcardText(displayNameOf(contact))}`);
lines.push(`N:${escapeVcardText(name)};;;;`);
for (const email of contact.emails) {
lines.push(
`EMAIL;TYPE=${VCARD_EMAIL_TYPES[email.label]}:${escapeVcardText(email.address)}`,
);
}
for (const phone of contact.phones) {
lines.push(
`TEL;TYPE=${VCARD_PHONE_TYPES[phone.label]}:${escapeVcardText(phone.number)}`,
);
}
if (contact.company !== null || contact.department !== null) {
lines.push(
`ORG:${escapeVcardText(contact.company ?? '')};${escapeVcardText(contact.department ?? '')}`,
);
}
if (contact.title !== null) {
lines.push(`TITLE:${escapeVcardText(contact.title)}`);
}
if (contact.note !== null) {
lines.push(`NOTE:${escapeVcardText(contact.note)}`);
}
if (contact.groupNames.length > 0) {
lines.push(
`CATEGORIES:${contact.groupNames.map(escapeVcardText).join(',')}`,
);
}
lines.push('END:VCARD');
return lines.map((line) => `${foldVcardLine(line)}${CRLF}`).join('');
}
/** vCard 3.0 파일 — 한 파일에 여러 장 (연락처가 없으면 빈 파일) */
export function contactsToVcard(contacts: readonly ExportedContact[]): string {
return contacts.map(vcardOf).join('');
}
/** CSV 한 칸 — 수식 막기 · 필요하면 따옴표 */
function csvCell(value: string): string {
const guarded = CSV_FORMULA_START.test(value) ? `'${value}` : value;
return CSV_NEEDS_QUOTES.test(guarded)
? `"${guarded.replace(/"/g, '""')}"`
: guarded;
}
/** 그 종류의 전화번호들 — 한 칸 */
function phonesOf(contact: ExportedContact, label: ContactPhoneLabel): string {
return contact.phones
.filter((phone) => phone.label === label)
.map((phone) => phone.number)
.join(CSV_JOINER);
}
function csvRow(contact: ExportedContact): string {
return [
contact.name ?? '',
contact.emails.map((email) => email.address).join(CSV_JOINER),
phonesOf(contact, 'mobile'),
phonesOf(contact, 'work'),
phonesOf(contact, 'home'),
phonesOf(contact, 'other'),
contact.company ?? '',
contact.department ?? '',
contact.title ?? '',
contact.note ?? '',
contact.groupNames.join(CSV_JOINER),
]
.map(csvCell)
.join(',');
}
/** CSV 파일 — BOM + 머리 줄 + 연락처마다 한 줄 (연락처가 없으면 머리 줄만) */
export function contactsToCsv(contacts: readonly ExportedContact[]): string {
const rows = [CSV_HEADER.join(','), ...contacts.map(csvRow)];
return `${UTF8_BOM}${rows.map((row) => `${row}${CRLF}`).join('')}`;
}
/** 앞뒤의 공백 · 점을 뗀다 — 반복 정규식 없이 한 번 훑는다(길이에 비례) */
function trimEdges(chars: readonly string[]): string[] {
let start = 0;
let end = chars.length;
while (start < end && FILE_NAME_EDGE.test(chars[start])) start += 1;
while (end > start && FILE_NAME_EDGE.test(chars[end - 1])) end -= 1;
return chars.slice(start, end);
}
/** 파일 이름의 그룹 이름 — 예약 글자는 '_', 앞뒤 공백 · 점은 떼고 50글자(코드 포인트)까지. 남는 것이 없으면 null */
function groupNamePart(name: string): string | null {
const chars = trimEdges(Array.from(name.replace(FILE_NAME_RESERVED, '_')));
const cleaned = trimEdges(chars.slice(0, MAX_CONTACT_GROUP_NAME_LENGTH)).join(
'',
);
return cleaned === '' ? null : cleaned;
}
/**
* 내려받을 파일 이름 — `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv` (날짜는 한국 날짜).
* 헤더 인코딩은 shared/utils/content-disposition.ts 가 한다
*/
export function contactExportFileName(
format: ContactExportFormat,
groupName: string | null,
at: Date,
): string {
const group = groupName === null ? null : groupNamePart(groupName);
const parts = [
FILE_NAME_PREFIX,
...(group ? [group] : []),
kstDateString(at),
];
return `${parts.join('-')}${FILE_EXTENSIONS[format]}`;
}
/** 형식마다 Content-Type */
export function contactExportContentType(format: ContactExportFormat): string {
return format === 'vcard' ? VCARD_CONTENT_TYPE : CSV_CONTENT_TYPE;
}
/** 형식마다 파일 내용 */
export function contactExportContent(
format: ContactExportFormat,
contacts: readonly ExportedContact[],
): string {
return format === 'vcard'
? contactsToVcard(contacts)
: contactsToCsv(contacts);
}
@@ -0,0 +1,90 @@
import {
Controller,
Get,
HttpStatus,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import {
ApiErrorResponse,
ApiSessionErrors,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import {
DOWNLOAD_CACHE_CONTROL,
DOWNLOAD_CSP,
} from '@/shared/utils/download-headers';
import { CSV_CONTENT_TYPE, VCARD_CONTENT_TYPE } from './contact-export';
import { CONTACT_EXPORTS_ROUTE } from './contacts.constants';
import { ContactsService } from './contacts.service';
import { ContactExportQueryDto } from './dto/contact-export.dto';
// 내 주소록 — 내보내기 (계약 3절 "내보내기", 16단계). 파일 응답이라 표준 래퍼 없이 내려준다 (실패는 표준 실패 JSON)
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_EXPORTS_ROUTE)
export class ContactExportsController {
constructor(private readonly contacts: ContactsService) {}
@Get()
@SkipTransform()
@ApiOperation({
summary: '주소록 내보내기 — vCard 3.0 · CSV 파일 (표준 래퍼 없음)',
description:
'내 주소록 전체 또는 그룹 하나(groupId)를 이름 차례로. 사내 목록은 넣지 않는다. 우리 칸만(엔진 카드의 다른 칸 — 주소 · 생일 · 사진 등은 넣지 않는다). ' +
'vcard: vCard 3.0 · CRLF · 75바이트 줄 접기 · FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL(TYPE=INTERNET,WORK / INTERNET,HOME / INTERNET) · TEL(CELL / WORK / HOME / VOICE) · ' +
'ORG(회사;부서) · TITLE · NOTE · CATEGORIES(그룹 이름) · UID. ' +
'csv: UTF-8 BOM · CRLF · 머리 「이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹」 — 같은 칸에 여럿이면 "; " 로 잇고, ' +
"= + - @ · 탭 · CR 로 시작하는 칸은 앞에 ' 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). " +
'파일 이름 「주소록-YYYY-MM-DD.vcf」 · 「주소록-<그룹 이름>-YYYY-MM-DD.csv」(한국 날짜) — Content-Disposition: attachment. Cache-Control: private, no-store. ' +
'화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 <a href download> 로 받는다.',
})
@ApiProduces(VCARD_CONTENT_TYPE, CSV_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: '내보낸 파일 (.vcf · .csv)',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse('VAL_001', '모르는 형식 · 그룹 id 모양 · 모르는 쿼리')
@ApiErrorResponse('RES_001', '없는 groupId')
async export(
@CurrentUser() user: AuthenticatedUser,
@Query() query: ContactExportQueryDto,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const file = await this.contacts.exportContacts(
user,
query.format,
query.groupId,
);
// 개인 정보 — 남기지 않는다. 브라우저가 열지 않고 내려받게(형식 짐작 막기 · 열어도 스크립트가 돌지 않게)
res.setHeader('Cache-Control', DOWNLOAD_CACHE_CONTROL);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', DOWNLOAD_CSP);
return new StreamableFile(file.content, {
type: file.contentType,
disposition: buildAttachmentDisposition(file.fileName),
length: file.content.length,
});
}
}
@@ -0,0 +1,171 @@
import {
deletionChunkSize,
importDuplicateIndex,
isExistingContact,
namePhonesKey,
phoneDigitsKey,
planGroupJoins,
type GroupRoom,
} from './contact-import';
import type { ContactFields, ContactPhone } from './contacts.types';
const mobile = (number: string): ContactPhone => ({ number, label: 'mobile' });
const person = (overrides: Partial<ContactFields> = {}) => ({
name: null,
emails: [],
phones: [],
...overrides,
});
describe('가져오기 — 이미 있는 연락처 (계약 3절 "가져오기")', () => {
it('전화 숫자 모음 — 숫자만 남기고 겹침 · 차례를 무시한다, 숫자가 없는 번호는 뺀다', () => {
expect(
phoneDigitsKey([
mobile('010-1234-5678'),
{ number: '(02) 555-0100', label: 'work' },
mobile('01012345678'),
mobile('#*'),
]),
).toBe('01012345678,025550100');
expect(phoneDigitsKey([])).toBe('');
});
it('이름 + 전화 열쇠 — 이름은 앞뒤 공백만 뺀다(가운데 공백 · 대소문자는 그대로), 이름이 없으면 null', () => {
expect(namePhonesKey({ name: ' 김아라 ', phones: [] })).toBe(
namePhonesKey({ name: '김아라', phones: [] }),
);
expect(namePhonesKey({ name: '김 아라', phones: [] })).not.toBe(
namePhonesKey({ name: '김아라', phones: [] }),
);
expect(namePhonesKey({ name: 'Ara', phones: [] })).not.toBe(
namePhonesKey({ name: 'ara', phones: [] }),
);
expect(namePhonesKey({ name: null, phones: [mobile('010')] })).toBeNull();
expect(namePhonesKey({ name: ' ', phones: [] })).toBeNull();
});
it('이메일이 있는 항목 — 그 가운데 하나라도 주소록에 있으면(앞뒤 공백 · 대소문자 무시) 이미 있음', () => {
const index = importDuplicateIndex([
person({
name: '박지훈',
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
}),
]);
expect(
isExistingContact(
index,
person({
emails: [
{ address: 'new@x.example', label: 'work' },
{ address: ' jihoon@hanbit.EXAMPLE ', label: 'other' },
],
}),
),
).toBe(true);
// 이메일이 있으면 이름 · 전화는 보지 않는다
expect(
isExistingContact(
index,
person({
name: '박지훈',
emails: [{ address: 'other@x.example', label: 'work' }],
}),
),
).toBe(false);
});
it('이메일이 없는 항목 — 이름과 전화 숫자 모음이 모두 같은 연락처가 있으면 이미 있음 (주소록 쪽은 이메일이 있어도 된다)', () => {
const index = importDuplicateIndex([
person({
name: '김철수',
emails: [{ address: 'kim@x.example', label: 'work' }],
phones: [
mobile('010-1111-2222'),
{ number: '02 555 0100', label: 'work' },
],
}),
person({ name: '이영희' }),
]);
expect(
isExistingContact(
index,
person({
name: '김철수 ',
phones: [
{ number: '0255501 00', label: 'other' },
mobile('01011112222'),
],
}),
),
).toBe(true);
// 번호 하나만 같으면 다른 사람으로 본다
expect(
isExistingContact(
index,
person({ name: '김철수', phones: [mobile('010-1111-2222')] }),
),
).toBe(false);
// 둘 다 전화가 없으면 이름만 같아도
expect(isExistingContact(index, person({ name: '이영희' }))).toBe(true);
expect(isExistingContact(index, person({ name: '이영희2' }))).toBe(false);
});
});
describe('가져오기 — 그룹 넣기 계획', () => {
const room = (members: string[], count = members.length): GroupRoom => ({
memberUids: new Set(members),
memberCount: count,
});
it('보낸 차례대로 넣고, 한도에 닿은 그룹은 그 뒤 사람만 빼고(skipped) 나머지 그룹에는 넣는다', () => {
const rooms = new Map([
['g1', room(['u-old'], 2)],
['g2', room([])],
]);
const plan = planGroupJoins(
rooms,
[
{ uid: 'a', groupIds: ['g1', 'g2'] },
{ uid: 'b', groupIds: ['g1', 'g2'] },
{ uid: 'c', groupIds: ['g2'] },
],
3,
);
expect(plan.adds.get('g1')).toEqual(['a']);
expect(plan.adds.get('g2')).toEqual(['a', 'b', 'c']);
expect(plan.skipped.get('b')).toEqual(['g1']);
expect(plan.skipped.has('a')).toBe(false);
});
it('이미 구성원인 uid 는 세지도 넣지도 않는다 · 모르는 그룹은 건너뛴다', () => {
const plan = planGroupJoins(
new Map([['g1', room(['a'], 3)]]),
[
{ uid: 'a', groupIds: ['g1', 'nope'] },
{ uid: 'b', groupIds: ['g1'] },
],
3,
);
expect(plan.adds.get('g1')).toBeUndefined();
expect(plan.skipped.get('b')).toEqual(['g1']);
expect(plan.skipped.has('a')).toBe(false);
});
});
describe('한꺼번에 지우기 — 한 번 몫', () => {
it('지울 연락처 수 + 고칠 그룹 수(겹침 없이)가 상한을 넘지 않게 앞에서부터', () => {
expect(deletionChunkSize([[], [], []], 500)).toBe(3);
expect(deletionChunkSize([['g1'], ['g1'], ['g2']], 4)).toBe(2);
expect(deletionChunkSize([['g1'], ['g1'], ['g1']], 4)).toBe(3);
expect(
deletionChunkSize(
Array.from({ length: 500 }, () => ['g1']),
500,
),
).toBe(499);
// 첫 사람이 홀로 넘어도 한 명은 지운다
expect(deletionChunkSize([['a', 'b', 'c']], 2)).toBe(1);
expect(deletionChunkSize([], 500)).toBe(0);
});
});
@@ -0,0 +1,138 @@
import { emailKey } from './address-book';
import type { ContactFields, ContactPhone } from './contacts.types';
/**
* 가져오기 · 한꺼번에 지우기의 순수 함수 (계약 3절 "가져오기 · 내보내기" — 16단계).
*
* "이미 있는 연락처" 규칙 — 화면이 같은 파일 안의 겹침을 **같은 규칙**으로 본다(frontend shared/utils/contact-import — 한쪽을 고치면 둘 다):
* - 이메일이 있는 항목: 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자 — emailKey) 내 주소록의 어느 연락처에 있으면 이미 있음
* - 이메일이 없는 항목: 이름(앞뒤 공백을 뺀 글)이 같고 전화번호의 숫자 모음(숫자만 남긴 번호들 — 차례 · 겹침 무시)이 같은 연락처가 있으면 이미 있음
* (주소록 쪽 연락처는 이메일이 있어도 된다. 둘 다 전화가 없으면 이름만 같아도 이미 있음)
*/
const NON_DIGITS = /\D+/g;
/** 이름과 전화 숫자 모음을 잇는 글 — 이름 칸에 올 수 없는 제어 문자 */
const KEY_SEPARATOR = '\u0000';
/** 전화번호들의 숫자 모음 — 숫자만 남긴 번호를 겹침 없이 차례로 (숫자가 없는 번호는 뺀다) */
export function phoneDigitsKey(phones: readonly ContactPhone[]): string {
const digits = new Set<string>();
for (const phone of phones) {
const only = phone.number.replace(NON_DIGITS, '');
if (only !== '') digits.add(only);
}
return [...digits].sort().join(',');
}
/** 이메일이 없는 항목을 견주는 열쇠 — 이름(앞뒤 공백을 뺀 글) + 전화 숫자 모음. 이름이 없으면 null */
export function namePhonesKey(
fields: Pick<ContactFields, 'name' | 'phones'>,
): string | null {
const name = fields.name?.trim() ?? '';
return name === ''
? null
: `${name}${KEY_SEPARATOR}${phoneDigitsKey(fields.phones)}`;
}
/** 내 주소록의 "이미 있음" 찾기 자료 — 이메일 모음 · 이름 + 전화 열쇠 모음 */
export interface ImportDuplicateIndex {
readonly emails: ReadonlySet<string>;
readonly namePhones: ReadonlySet<string>;
}
/** 주소록의 연락처들로 찾기 자료를 만든다 — 연락처 수에 비례 */
export function importDuplicateIndex(
contacts: readonly Pick<ContactFields, 'name' | 'emails' | 'phones'>[],
): ImportDuplicateIndex {
const emails = new Set<string>();
const namePhones = new Set<string>();
for (const contact of contacts) {
for (const email of contact.emails) emails.add(emailKey(email.address));
const key = namePhonesKey(contact);
if (key !== null) namePhones.add(key);
}
return { emails, namePhones };
}
/** 이 항목이 내 주소록에 이미 있는가 (위 규칙) */
export function isExistingContact(
index: ImportDuplicateIndex,
item: Pick<ContactFields, 'name' | 'emails' | 'phones'>,
): boolean {
if (item.emails.length > 0) {
return item.emails.some((email) =>
index.emails.has(emailKey(email.address)),
);
}
const key = namePhonesKey(item);
return key !== null && index.namePhones.has(key);
}
/** 그룹 하나의 지금 자리 — 구성원 uid(이 주소록에 없는 카드를 가리키는 것 포함)와 센 구성원 수(이 주소록에 있는 연락처만) */
export interface GroupRoom {
readonly memberUids: ReadonlySet<string>;
readonly memberCount: number;
}
/** 그룹에 넣을 사람 하나 — uid 와 넣을 그룹 */
export interface GroupJoin {
readonly uid: string;
readonly groupIds: readonly string[];
}
export interface GroupJoinPlan {
/** 그룹 id → 새로 넣을 uid (넣는 차례) */
readonly adds: ReadonlyMap<string, readonly string[]>;
/** uid → 구성원 수 한도가 차서 넣지 못한 그룹 id (보낸 차례) */
readonly skipped: ReadonlyMap<string, readonly string[]>;
}
/**
* 가져온 사람들을 그룹에 넣는 계획 — 보낸 차례대로 넣고, 그룹이 max 명에 닿으면 그 뒤 사람은 그 그룹만 빼고(skipped) 나머지 그룹에는 넣는다.
* 이미 구성원인 uid 는 세지도 넣지도 않는다. 모르는 그룹 id 는 부르는 쪽이 먼저 막는다(여기서는 건너뛴다)
*/
export function planGroupJoins(
rooms: ReadonlyMap<string, GroupRoom>,
joins: readonly GroupJoin[],
max: number,
): GroupJoinPlan {
const counts = new Map<string, number>();
const adds = new Map<string, string[]>();
const skipped = new Map<string, string[]>();
for (const join of joins) {
for (const groupId of join.groupIds) {
const room = rooms.get(groupId);
if (!room) continue;
const added = adds.get(groupId) ?? [];
if (room.memberUids.has(join.uid) || added.includes(join.uid)) continue;
const count = counts.get(groupId) ?? room.memberCount;
if (count >= max) {
skipped.set(join.uid, [...(skipped.get(join.uid) ?? []), groupId]);
continue;
}
counts.set(groupId, count + 1);
added.push(join.uid);
adds.set(groupId, added);
}
}
return { adds, skipped };
}
/**
* 한꺼번에 지우기의 한 번 몫 — 앞에서부터 몇 명을 지울지. 지울 연락처 수 + 고칠 그룹 수(그 연락처가 든 그룹 — 겹침 없이)가 max 를 넘지 않게
* (엔진 한 요청의 카드 수 상한). 첫 사람이 홀로 넘어도 한 명은 지운다(그런 주소록은 없다 — 그룹 100개)
*/
export function deletionChunkSize(
groupsOf: readonly (readonly string[])[],
max: number,
): number {
const groups = new Set<string>();
let size = 0;
for (const ids of groupsOf) {
const fresh = ids.filter((id) => !groups.has(id));
if (size > 0 && size + 1 + groups.size + fresh.length > max) break;
for (const id of fresh) groups.add(id);
size += 1;
}
return size;
}
@@ -0,0 +1,149 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
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 {
CONTACT_DELETIONS_ROUTE,
CONTACT_IMPORT_CHECKS_ROUTE,
CONTACT_IMPORTS_ROUTE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_IMPORT_BATCH,
MAX_CONTACTS,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import {
ContactDeletionDto,
ContactImportBatchDto,
ContactImportCheckDto,
ContactImportResultDto,
ContactsDeletedDto,
} from './dto/contact-import.dto';
const ITEM_RULES =
`한 사람의 칸 규칙은 연락처 추가(POST /api/contacts)와 같고 uid(urn:uuid: + 소문자 UUID — 화면이 만든다)를 더한다. ` +
`${MAX_CONTACT_IMPORT_BATCH}명까지 · 한 묶음 안의 같은 uid · 이름도 이메일도 없는 사람 · 같은 연락처 안의 같은 주소는 400 VAL_001(하나라도 걸리면 묶음 전체).`;
// 내 주소록 — 가져오기 확인 (계약 3절 "가져오기", 16단계). 파일은 화면이 읽는다 — 서버는 연락처 칸만 받는다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_IMPORT_CHECKS_ROUTE)
export class ContactImportChecksController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '가져올 연락처가 이미 있는지 확인 (만들지 않는다)',
description:
'사람마다 new · exists — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있으면, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있으면, ' +
`또는 같은 uid 의 연락처가 있으면 exists. remaining 은 더 넣을 수 있는 수(${MAX_CONTACTS.toLocaleString('en-US')} − 지금 연락처 수). ` +
`같은 파일 안의 겹침은 화면이 같은 규칙으로 본다. groupIds 는 보지 않는다. ${ITEM_RULES}`,
})
@ApiStandardResponse(ContactImportCheckDto, {
description: '사람마다 판정과 남은 자리',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
)
check(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactImportBatchDto,
) {
return this.contacts.checkImport(user, dto.contacts);
}
}
// 내 주소록 — 가져오기 넣기 (계약 3절 "가져오기", 16단계). 엔진 요청 하나에 묶음을 만든다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_IMPORTS_ROUTE)
export class ContactImportsController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@ApiOperation({
summary: '가져온 연락처 넣기 (100명씩)',
description:
'줄마다 보낸 uid 로 만든다 — 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다(빠진 그룹만 마저 넣는다). ' +
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도는 묶음 전체로 미리 본다 — 넘으면 409 CONTACT_002, 아무것도 만들지 않는다. ` +
`그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명을 넘기는 넣기는 그 그룹만 빼고 만들고 skippedGroupIds 에 적는다(실패가 아니다). ` +
'메일 서버가 일부만 받으면 받은 것만 items 에 — 빠진 uid 는 넣지 못한 것이다. 겹침(같은 이메일 등)은 보지 않는다(확인 경로의 몫). ' +
ITEM_RULES,
})
@ApiStandardResponse(ContactImportResultDto, {
status: 201,
description: '넣은 연락처(같은 uid 로 이미 있던 것 포함)',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
)
@ApiErrorResponse('RES_001', '없는 groupId')
@ApiErrorResponse(
'CONTACT_002',
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도 — 묶음 전체로 보고 아무것도 만들지 않았다`,
)
importContacts(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactImportBatchDto,
) {
return this.contacts.importContacts(user, dto.contacts);
}
}
// 내 주소록 — 연락처 한꺼번에 지우기 (가져오기 [되돌리기] — 계약 3절 "가져오기", 16단계)
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_DELETIONS_ROUTE)
export class ContactDeletionsController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '연락처 한꺼번에 지우기 (영구 삭제 · 되돌리기 표 없음)',
description:
`1~${MAX_CONTACT_DELETIONS}개. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다. 내 주소록에 없는 id(이미 지움 · 그룹 id)는 notFoundIds — 실패가 아니다. ` +
'메일 서버 한 요청의 상한을 넘으면 서버가 나눠 지운다 — 중간에 실패하면(503) 앞 몫은 지워진 채다(같은 목록을 다시 보내면 notFoundIds 로 나온다).',
})
@ApiStandardResponse(ContactsDeletedDto, {
description: '지운 id · 없던 id (보낸 차례)',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 개수 · id 모양 · 같은 id 두 번 · 모르는 필드',
)
remove(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactDeletionDto,
) {
return this.contacts.removeContacts(user, dto.ids);
}
}
@@ -14,6 +14,28 @@ export const CONTACT_GROUPS_ROUTE = 'contact-groups';
export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations';
export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries';
/**
* 16단계 라우트 — 가져오기의 확인 · 넣기, 한꺼번에 지우기(가져오기 [되돌리기]), 내보내기 (계약 3절 "가져오기 · 내보내기")
* (`/api/contact-import-checks` · `/api/contact-imports` · `/api/contact-deletions` · `/api/contact-exports`)
*/
export const CONTACT_IMPORT_CHECKS_ROUTE = 'contact-import-checks';
export const CONTACT_IMPORTS_ROUTE = 'contact-imports';
export const CONTACT_DELETIONS_ROUTE = 'contact-deletions';
export const CONTACT_EXPORTS_ROUTE = 'contact-exports';
/** 가져오기 · 확인 한 요청의 연락처 수 — 1~100 (화면은 100명씩 차례로 보낸다) */
export const MAX_CONTACT_IMPORT_BATCH = 100;
/** 한꺼번에 지우기 한 요청의 id 수 — 1~500 */
export const MAX_CONTACT_DELETIONS = 500;
/**
* 엔진 한 번의 바꾸기(ContactCard/set)에 담는 카드 수 — 만들기 · 고치기 · 지우기를 모두 센다.
* 엔진의 maxObjectsInSet(500 — 2026-11-10 세션 능력 확인)과 같다. 한꺼번에 지우기는 그룹 고치기와 합쳐 이것을 넘으면 나눠 쓴다
*/
export const MAX_CARD_CHANGES_PER_WRITE = 500;
/** 가져오기 항목의 uid — `urn:uuid:` + 소문자 UUID (화면이 만든다 — 다시 보낸 요청이 두 번 들어가지 않게) */
export const CONTACT_IMPORT_UID_PATTERN =
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다).
* ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다
@@ -3,7 +3,13 @@ import { Module } from '@nestjs/common';
import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider';
import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider';
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
import { ContactExportsController } from './contact-exports.controller';
import { ContactGroupsController } from './contact-groups.controller';
import {
ContactDeletionsController,
ContactImportChecksController,
ContactImportsController,
} from './contact-imports.controller';
import { ContactRestorationsController } from './contact-restorations.controller';
import { ContactsController } from './contacts.controller';
import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider';
@@ -25,6 +31,11 @@ import { DirectoryService } from './directory.service';
ContactGroupsController,
ContactRestorationsController,
DirectoryEntriesController,
// 16단계 — 가져오기 확인 · 넣기 · 한꺼번에 지우기 · 내보내기
ContactImportChecksController,
ContactImportsController,
ContactDeletionsController,
ContactExportsController,
],
providers: [
{ provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider },
@@ -103,16 +103,30 @@ export type AddressBookCardUpdate =
readonly memberUids?: readonly string[];
};
/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */
/**
* 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다).
* 카드 수(만들기 + 고치기 + 지우기)는 서비스가 MAX_CARD_CHANGES_PER_WRITE(엔진의 한 요청 상한) 안으로 맞춘다
*/
export interface AddressBookChanges {
readonly create: readonly NewAddressBookCard[];
readonly update: readonly AddressBookCardUpdate[];
readonly destroy: readonly string[];
}
/** 바꾸기의 선택 (16단계 — 가져오기 · 한꺼번에 지우기) */
export interface AddressBookWriteOptions {
/**
* 엔진이 받지 않은 **만들기**를 던지지 않고 결과(written 의 refusedKeys)에 적는다 — 가져오기.
* 받은 만들기 · 고치기는 들어간다. 고치기 · 지우기의 거절과 동시 고치기 거절(busy)은 이 선택과 상관없이 지금처럼 다룬다
*/
readonly allowPartialCreate?: boolean;
/** 지울 카드의 원본(되돌리기 표)을 읽지 않는다 — 한꺼번에 지우기. 그 판에 카드가 있는지는 그대로 본다(없으면 conflict) */
readonly withoutOriginals?: boolean;
}
/**
* 바꾸기의 결과
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) ·
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다 — allowPartialCreate 면 refusedKeys 를 뺀 것) ·
* 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본)
* - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다)
* - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다
@@ -125,6 +139,8 @@ export type AddressBookWriteOutcome =
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
/** 엔진이 받지 않은 만들기의 key — allowPartialCreate 로 쓸 때만 있다(없으면 모두 받았다) */
readonly refusedKeys?: ReadonlySet<string>;
}
| { readonly kind: 'conflict' }
| {
@@ -143,12 +159,13 @@ export interface ContactsProvider {
* 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음).
* 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict).
* 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 —
* 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
* 받은 것은 이미 바뀌었을 수 있다 — options.allowPartialCreate 면 받지 않은 만들기만은 던지지 않고 결과에 적는다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
*/
writeAddressBook(
user: string,
version: string,
changes: AddressBookChanges,
options?: AddressBookWriteOptions,
): Promise<AddressBookWriteOutcome>;
}
@@ -2,11 +2,14 @@ import { Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { kstDateString } from '@/shared/utils/kst-date';
import { cardWeight } from './address-book';
import {
CONTACT_RESTORE_MAX_CARD_BYTES,
CONTACT_RESTORE_TTL_MS,
MAX_ADDRESS_BOOK_WEIGHT,
MAX_CARD_CHANGES_PER_WRITE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACTS,
@@ -17,6 +20,7 @@ import type {
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
AddressBookWriteOptions,
AddressBookWriteOutcome,
ContactsProvider,
RemovedCardOriginal,
@@ -24,6 +28,7 @@ import type {
import { ContactsService } from './contacts.service';
import type {
ContactFields,
ContactImportItem,
ContactInput,
DirectoryEntry,
} from './contacts.types';
@@ -61,6 +66,10 @@ class FakeAddressBook implements ContactsProvider {
failure: BusinessException | null = null;
/** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */
readonly busy: ('lost' | 'applied')[] = [];
/** 엔진이 만들기를 받지 않을 uid (16단계 — 가져오기의 일부만 받기) */
readonly refuseUids = new Set<string>();
/** 쓰기마다 받은 선택 */
readonly writeOptions: (AddressBookWriteOptions | undefined)[] = [];
private nextId = 1;
readonly getAddressBookVersion = jest.fn(() =>
@@ -77,7 +86,9 @@ class FakeAddressBook implements ContactsProvider {
_user: string,
version: string,
changes: AddressBookChanges,
options?: AddressBookWriteOptions,
): Promise<AddressBookWriteOutcome> => {
this.writeOptions.push(options);
if (this.outsideChanges > 0) {
this.outsideChanges -= 1;
this.state += 1;
@@ -91,14 +102,30 @@ class FakeAddressBook implements ContactsProvider {
this.state += 1;
return Promise.reject(failure);
}
const refusedKeys = new Set(
changes.create
.filter((card) => this.refuseUids.has(card.uid))
.map((card) => card.key),
);
if (refusedKeys.size > 0 && !options?.allowPartialCreate) {
// 엔진이 받지 않은 만들기 — 일부만 받기가 아니면 그 코드로 던진다(받은 것은 들어갔다)
this.apply(changes, refusedKeys);
return Promise.reject(
new BusinessException('VAL_001', {
context: { reason: 'address-book-change-refused' },
}),
);
}
const removed = new Map<string, RemovedCardOriginal>();
for (const id of changes.destroy) {
const card = this.contacts.get(id) ?? this.groups.get(id);
if (card) removed.set(id, this.original(card));
if (!options?.withoutOriginals) {
for (const id of changes.destroy) {
const card = this.contacts.get(id) ?? this.groups.get(id);
if (card) removed.set(id, this.original(card));
}
}
const mode = this.busy.shift();
if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed });
const { createdIds, cards } = this.apply(changes);
const { createdIds, cards } = this.apply(changes, refusedKeys);
if (mode === 'applied') {
return Promise.resolve({ kind: 'busy', removed });
}
@@ -108,6 +135,7 @@ class FakeAddressBook implements ContactsProvider {
createdIds,
cards,
removed,
...(options?.allowPartialCreate ? { refusedKeys } : {}),
});
},
);
@@ -165,7 +193,10 @@ class FakeAddressBook implements ContactsProvider {
return { source, bytes: Buffer.byteLength(source, 'utf8') };
}
private apply(changes: AddressBookChanges): {
private apply(
changes: AddressBookChanges,
refusedKeys: ReadonlySet<string> = new Set(),
): {
createdIds: Map<string, string>;
cards: Map<string, AddressBookCard>;
} {
@@ -173,6 +204,7 @@ class FakeAddressBook implements ContactsProvider {
const createdIds = new Map<string, string>();
const cards = new Map<string, AddressBookCard>();
for (const card of changes.create) {
if (refusedKeys.has(card.key)) continue;
const id = `n${this.nextId++}`;
createdIds.set(card.key, id);
if (card.kind === 'contact') {
@@ -1364,4 +1396,413 @@ describe('ContactsService (주소록)', () => {
expect(book.contacts.size).toBe(2);
});
});
describe('16단계 — 가져오기 · 한꺼번에 지우기 · 내보내기 (계약 3절 "가져오기 · 내보내기")', () => {
const uidOf = (n: number) =>
`urn:uuid:00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
const item = (
n: number,
overrides: Partial<ContactImportItem> = {},
): ContactImportItem => ({
...emptyFields,
name: `사람${n}`,
emails: [{ address: `p${n}@import.example`, label: 'work' }],
groupIds: [],
uid: uidOf(n),
...overrides,
});
const logged = () =>
logSpy.mock.calls.map(([message]) => String(message)).join('\n');
describe('가져오기 확인 (checkImport)', () => {
it('같은 이메일(대소문자 무시) · 이름 + 전화 숫자 · 같은 uid 는 exists, 나머지는 new — remaining 은 3,000 − 지금 수, 쓰지 않는다', async () => {
book.seedContact('c1', {
name: '박지훈',
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
});
book.seedContact('c2', {
name: '김철수',
phones: [{ number: '010-1111-2222', label: 'mobile' }],
});
const result = await service.checkImport(ARA, [
item(1, {
emails: [{ address: ' jihoon@hanbit.EXAMPLE', label: 'personal' }],
}),
item(2, {
name: '김철수',
emails: [],
phones: [{ number: '01011112222', label: 'other' }],
}),
item(3, { uid: 'urn:uuid:c1' }),
item(4),
item(5, { name: '김철수', emails: [], phones: [] }),
]);
expect(result).toEqual({
items: [
{ uid: uidOf(1), status: 'exists' },
{ uid: uidOf(2), status: 'exists' },
{ uid: 'urn:uuid:c1', status: 'exists' },
{ uid: uidOf(4), status: 'new' },
{ uid: uidOf(5), status: 'new' },
],
remaining: MAX_CONTACTS - 2,
});
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
it('remaining 은 0 아래로 내려가지 않는다 (다른 프로그램이 3,000명 넘게 넣은 주소록)', async () => {
for (let index = 0; index < MAX_CONTACTS + 2; index += 1) {
book.seedContact(`s${index}`);
}
await expect(
service.checkImport(ARA, [item(1)]),
).resolves.toMatchObject({ remaining: 0 });
});
it('묶음 검사 — 0명 · 101명 · 같은 uid · 이름도 이메일도 없음 · 같은 주소 두 번은 엔진에 묻기 전에 VAL_001', async () => {
const batches: ContactImportItem[][] = [
[],
Array.from({ length: 101 }, (_, n) => item(n)),
[item(1), item(2, { uid: uidOf(1) })],
[item(1, { name: null, emails: [] })],
[
item(1, {
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'A@X.example', label: 'other' },
],
}),
],
];
for (const batch of batches) {
expect((await failureOf(service.checkImport(ARA, batch))).code).toBe(
'VAL_001',
);
expect(
(await failureOf(service.importContacts(ARA, batch))).code,
).toBe('VAL_001');
}
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
});
describe('가져오기 넣기 (importContacts)', () => {
it('보낸 uid 로 엔진 요청 하나에 만들고 고른 그룹에 넣는다 — 응답은 보낸 차례, 다시 읽지 않고 바로 목록에 보인다', async () => {
book.seedGroup('g1', '거래처');
book.seedGroup('g2', '협력사');
const result = await service.importContacts(ARA, [
item(2, { groupIds: ['g1', 'g2'] }),
item(1, { groupIds: ['g1'] }),
]);
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
expect(book.writeOptions).toEqual([{ allowPartialCreate: true }]);
const [write] = book.writes;
expect(write.create.map((card) => card.uid)).toEqual([
uidOf(2),
uidOf(1),
]);
expect(result.items).toEqual([
{
uid: uidOf(2),
id: expect.any(String) as unknown,
skippedGroupIds: [],
},
{
uid: uidOf(1),
id: expect.any(String) as unknown,
skippedGroupIds: [],
},
]);
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2), uidOf(1)]);
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(2)]);
const reads = book.reads;
const page = await service.list(ARA, listQuery());
expect(page.items.map((contact) => contact.name)).toEqual([
'사람1',
'사람2',
]);
expect(page.items[1].groupIds).toEqual(['g1', 'g2']);
expect(book.reads).toBe(reads);
});
it('같은 묶음을 다시 보내면(응답을 잃음) 만들지 않고 같은 id 를 준다 — 빠진 그룹만 마저 넣는다', async () => {
book.seedGroup('g1', '거래처');
const first = await service.importContacts(ARA, [item(1), item(2)]);
const writes = book.writes.length;
const again = await service.importContacts(ARA, [
item(1),
item(2, { groupIds: ['g1'] }),
]);
expect(again.items.map((entry) => entry.id)).toEqual(
first.items.map((entry) => entry.id),
);
expect(book.contacts.size).toBe(2);
// 다시 보낸 쓰기에는 만들기가 없고 그룹 넣기만 있다
expect(book.writes).toHaveLength(writes + 1);
expect(book.writes.at(-1)?.create).toEqual([]);
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2)]);
});
it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 그 그룹만 빼고(skippedGroupIds) 만들고 다른 그룹에는 넣는다`, async () => {
const members: string[] = [];
for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) {
members.push(book.seedContact(`m${index}`).uid ?? '');
}
book.seedGroup('g1', '거의 참', members);
book.seedGroup('g2', '여유');
const result = await service.importContacts(ARA, [
item(1, { groupIds: ['g1', 'g2'] }),
item(2, { groupIds: ['g1', 'g2'] }),
]);
expect(result.items.map((entry) => entry.skippedGroupIds)).toEqual([
[],
['g1'],
]);
expect(book.groups.get('g1')?.memberUids).toHaveLength(
MAX_CONTACT_GROUP_MEMBERS,
);
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(1), uidOf(2)]);
expect(logged()).toContain('구성원 한도로 그룹에 넣지 못함 1건');
});
it(`연락처가 ${MAX_CONTACTS}명을 넘게 되면 묶음 전체를 CONTACT_002 — 아무것도 만들지 않는다 · 없는 그룹은 RES_001`, async () => {
for (let index = 0; index < MAX_CONTACTS - 1; index += 1) {
book.seedContact(`s${index}`);
}
const failure = await failureOf(
service.importContacts(ARA, [item(1), item(2)]),
);
expect(failure.code).toBe('CONTACT_002');
expect(failure.context?.reason).toBe('contacts-limit');
expect(
(
await failureOf(
service.importContacts(ARA, [item(1, { groupIds: ['nope'] })]),
)
).code,
).toBe('RES_001');
expect(book.writeAddressBook).not.toHaveBeenCalled();
// 한 명은 들어간다
await expect(
service.importContacts(ARA, [item(1)]),
).resolves.toMatchObject({ items: [{ uid: uidOf(1) }] });
});
it(`크기 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 를 넘게 하는 묶음은 CONTACT_002 — 아무것도 만들지 않는다`, async () => {
// 792만 남짓 — 한 명은 들어가고 100명 묶음(사람마다 1,100 남짓)은 넘는다
const note = 'x'.repeat(990_000);
for (let index = 0; index < 8; index += 1) {
book.seedContact(`big${index}`, { note });
}
const failure = await failureOf(
service.importContacts(
ARA,
Array.from({ length: 100 }, (_, n) =>
item(n, { note: 'y'.repeat(1_000) }),
),
),
);
expect(failure.code).toBe('CONTACT_002');
expect(failure.context?.reason).toBe('address-book-too-large');
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
it('엔진이 일부만 받으면 받은 것만 돌려준다 — 던지지 않고, 기억한 주소록도 엔진과 같다(다시 읽지 않는다)', async () => {
book.refuseUids.add(uidOf(2));
const result = await service.importContacts(ARA, [
item(1),
item(2),
item(3),
]);
expect(result.items.map((entry) => entry.uid)).toEqual([
uidOf(1),
uidOf(3),
]);
expect(book.contacts.size).toBe(2);
const reads = book.reads;
const page = await service.list(ARA, listQuery());
expect(page.items.map((contact) => contact.name)).toEqual([
'사람1',
'사람3',
]);
expect(book.reads).toBe(reads);
expect(logged()).toContain('넣지 못함 1명');
});
it('엔진이 바쁘다고 했지만 들어갔으면(applied) 다음 시도가 같은 uid 로 찾아 두 번 만들지 않는다', async () => {
book.busy.push('applied');
const result = await service.importContacts(ARA, [item(1), item(2)]);
expect(result.items.map((entry) => entry.uid)).toEqual([
uidOf(1),
uidOf(2),
]);
expect(book.contacts.size).toBe(2);
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
});
it('그룹 카드가 쓰는 uid 는 만들지 않는다(엔진이 받지 않는다) — 응답에서 빠진다', async () => {
book.seedGroup('g1', '거래처');
const result = await service.importContacts(ARA, [
item(1, { uid: 'urn:uuid:g1' }),
item(2),
]);
expect(result.items.map((entry) => entry.uid)).toEqual([uidOf(2)]);
expect(book.contacts.size).toBe(1);
});
it('로그에는 개수만 — 이름 · 주소 · uid 는 남기지 않는다', async () => {
await service.importContacts(ARA, [item(1, { note: '비밀 메모' })]);
expect(logged()).toContain('연락처를 가져왔습니다');
expect(logged()).not.toMatch(
/사람1|import\.example|비밀 메모|urn:uuid|ara\.kim@/i,
);
});
});
describe('한꺼번에 지우기 (removeContacts)', () => {
it('지우고 그룹 구성원에서도 뺀다 — 없는 id · 그룹 id 는 notFoundIds(보낸 차례), 되돌리기 원본은 읽지 않는다', async () => {
book.seedContact('c1');
book.seedContact('c2');
book.seedContact('c3');
book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c3']);
const result = await service.removeContacts(ARA, [
'c3',
'gone',
'c1',
'g1',
]);
expect(result).toEqual({
deletedIds: ['c3', 'c1'],
notFoundIds: ['gone', 'g1'],
});
expect(book.writeOptions).toEqual([{ withoutOriginals: true }]);
expect([...book.contacts.keys()]).toEqual(['c2']);
expect(book.groups.get('g1')?.memberUids).toEqual([]);
expect(logged()).toContain('연락처를 한꺼번에 지웠습니다: 2명');
});
it(`지울 연락처 + 고칠 그룹이 엔진 한 요청의 상한(${MAX_CARD_CHANGES_PER_WRITE})을 넘으면 앞에서부터 나눠 쓴다`, async () => {
const ids: string[] = [];
const uids: string[] = [];
for (let index = 0; index < MAX_CONTACT_DELETIONS; index += 1) {
const card = book.seedContact(`d${index}`);
ids.push(card.id);
uids.push(card.uid ?? '');
}
book.seedGroup('g1', '모두', uids);
const result = await service.removeContacts(ARA, ids);
expect(result.deletedIds).toHaveLength(MAX_CONTACT_DELETIONS);
expect(result.notFoundIds).toEqual([]);
expect(book.writes.map((write) => write.destroy.length)).toEqual([
MAX_CARD_CHANGES_PER_WRITE - 1,
1,
]);
for (const write of book.writes) {
expect(
write.destroy.length + write.update.length,
).toBeLessThanOrEqual(MAX_CARD_CHANGES_PER_WRITE);
}
expect(book.contacts.size).toBe(0);
expect(book.groups.get('g1')?.memberUids).toEqual([]);
});
it('같은 uid 의 다른 카드가 남으면 그 uid 는 그룹에 그대로 둔다', async () => {
book.seedContact('c1', { uid: 'urn:uuid:shared' });
book.seedContact('c2', { uid: 'urn:uuid:shared' });
book.seedGroup('g1', '거래처', ['urn:uuid:shared']);
await service.removeContacts(ARA, ['c1']);
expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:shared']);
expect(book.writes.at(-1)?.update).toEqual([]);
});
it('바쁘다고 했지만 들어갔으면 — 다음 시도에서 없어진 id 도 지운 것으로 센다(notFound 가 아니다)', async () => {
book.seedContact('c1');
book.seedContact('c2');
book.busy.push('applied');
await expect(
service.removeContacts(ARA, ['c1', 'c2']),
).resolves.toEqual({ deletedIds: ['c1', 'c2'], notFoundIds: [] });
});
it('0개 · 501개 · 같은 id 두 번은 VAL_001 — 엔진에 묻지 않는다', async () => {
for (const ids of [
[],
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `x${n}`),
['c1', 'c1'],
]) {
expect((await failureOf(service.removeContacts(ARA, ids))).code).toBe(
'VAL_001',
);
}
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
});
});
describe('내보내기 (exportContacts)', () => {
beforeEach(() => {
book.seedContact('c-pa', {
name: '박지훈',
emails: [{ address: 'jihoon@hanbit.example', label: 'work' }],
});
book.seedContact('c-ga', {
name: '가나다',
phones: [{ number: '010-1234-5678', label: 'mobile' }],
});
book.seedGroup('g1', '거래처', ['urn:uuid:c-pa']);
});
it('vCard — 내 주소록 전체를 이름 차례로, 그룹 이름은 CATEGORIES · 파일 이름은 한국 날짜', async () => {
const file = await service.exportContacts(ARA, 'vcard');
const text = file.content.toString('utf8');
expect(file.fileName).toBe(
`주소록-${kstDateString(new Date(now))}.vcf`,
);
expect(file.contentType).toBe('text/vcard; charset=utf-8');
expect(file.count).toBe(2);
expect(text.indexOf('FN:가나다')).toBeLessThan(
text.indexOf('FN:박지훈'),
);
expect(text).toContain('UID:urn:uuid:c-pa\r\n');
expect(text).toContain('CATEGORIES:거래처\r\n');
expect(logged()).toContain(
'주소록을 내보냈습니다: 형식=vcard, 연락처 2명',
);
expect(logged()).not.toMatch(/박지훈|가나다|hanbit|ara\.kim@/);
});
it('CSV — 그룹 하나만(groupId), 파일 이름에 그룹 이름 · 없는 그룹은 RES_001', async () => {
const file = await service.exportContacts(ARA, 'csv', 'g1');
const rows = file.content.toString('utf8').split('\r\n');
expect(file.fileName).toBe(
`주소록-거래처-${kstDateString(new Date(now))}.csv`,
);
expect(rows).toHaveLength(3);
expect(rows[1]).toBe('박지훈,jihoon@hanbit.example,,,,,,,,,거래처');
expect(
(await failureOf(service.exportContacts(ARA, 'csv', 'nope'))).code,
).toBe('RES_001');
});
});
});
});
@@ -20,6 +20,19 @@ import {
type WrittenChanges,
} from './address-book';
import { ContactBookCache } from './contact-book-cache';
import {
contactExportContent,
contactExportContentType,
contactExportFileName,
type ExportedContact,
} from './contact-export';
import {
deletionChunkSize,
importDuplicateIndex,
isExistingContact,
planGroupJoins,
type GroupRoom,
} from './contact-import';
import { cursorOffset, listScope, pageOf } from './contact-paging';
import {
ContactRestoreStore,
@@ -38,8 +51,11 @@ import {
CONTACT_RESTORE_TTL_MS,
CONTACT_WRITE_ATTEMPTS,
MAX_ADDRESS_BOOK_WEIGHT,
MAX_CARD_CHANGES_PER_WRITE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACT_IMPORT_BATCH,
MAX_CONTACTS,
MAX_GROUP_MEMBERS_PER_REQUEST,
} from './contacts.constants';
@@ -48,6 +64,7 @@ import {
type AddressBookCardUpdate,
type AddressBookChanges,
type AddressBookContactCard,
type AddressBookWriteOptions,
type ContactsProvider,
type NewAddressBookCard,
type RemovedCardOriginal,
@@ -56,14 +73,20 @@ import type {
Contact,
ContactDeleted,
ContactEmail,
ContactExportFile,
ContactExportFormat,
ContactFields,
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
ContactImportCheck,
ContactImportItem,
ContactImportResult,
ContactInput,
ContactPatch,
ContactPhone,
ContactRestored,
ContactsDeleted,
ContactSuggestionLine,
DirectoryEntry,
} from './contacts.types';
@@ -79,6 +102,8 @@ const NEW_CONTACT_KEY = 'contact';
const NEW_GROUP_KEY = 'group';
const NEW_MEMBER_KEY_PREFIX = 'member-';
const RESTORED_KEY = 'restored';
/** 가져오기의 한 사람 — 보낸 차례 번호를 붙인다 (import-0 …) */
const IMPORT_KEY_PREFIX = 'import-';
const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] };
@@ -105,6 +130,8 @@ interface ChangePlan<T> {
readonly result: (after: BookView, written: WriteResult) => T;
/** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */
readonly log?: string;
/** 엔진에 쓰는 선택 — 가져오기(일부만 받기) · 한꺼번에 지우기(원본 읽지 않기) (16단계) */
readonly write?: AddressBookWriteOptions;
}
/** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */
@@ -269,7 +296,9 @@ function joinGroups(groups: readonly BookGroup[], uid: string) {
* 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 —
* CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts)
* - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts)
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다
* - 16단계 — 가져오기(확인 · 100명씩 넣기 — 화면이 준 uid 로 두 번 들어가지 않게, 엔진이 일부만 받아도 던지지 않는다 · contact-import.ts),
* 한꺼번에 지우기(가져오기 [되돌리기] — 되돌리기 표 없음), 내보내기(vCard · CSV — contact-export.ts)
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표 · 파일 내용은 남기지 않는다
*
* ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다.
* 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404).
@@ -288,12 +317,15 @@ export class ContactsService {
private readonly loading = new Map<string, Loading>();
/** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */
private readonly writeQueues = new Map<string, Promise<void>>();
/** 되돌리기 표의 시계 · 내보낸 파일 이름의 날짜 */
private readonly now: () => number;
constructor(
@Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider,
private readonly directory: DirectoryService,
@Optional() @Inject(CONTACTS_CLOCK) clock?: () => number,
) {
this.now = clock ?? (() => Date.now());
this.restorations = new ContactRestoreStore(
{
ttlMs: CONTACT_RESTORE_TTL_MS,
@@ -302,7 +334,7 @@ export class ContactsService {
maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES,
tokenBytes: CONTACT_RESTORE_TOKEN_BYTES,
},
clock ?? (() => Date.now()),
this.now,
);
}
@@ -840,6 +872,261 @@ export class ContactsService {
});
}
/**
* POST /api/contact-import-checks — 가져올 연락처가 내 주소록에 이미 있는지 · 더 넣을 수 있는 수 (계약 3절 "가져오기" — 16단계). 만들지 않는다.
* 이미 있음: 같은 uid 의 연락처(다시 보낸 묶음) 또는 contact-import.ts 의 규칙(이메일 · 이름 + 전화). 항목 검사(이름이나 이메일 · 같은 주소 · 같은 uid)는
* 넣기와 같다 — 하나라도 걸리면 VAL_001. groupIds 는 보지 않는다(새 그룹은 넣기 직전에 만든다)
*/
async checkImport(
user: AuthenticatedUser,
items: readonly ContactImportItem[],
): Promise<ContactImportCheck> {
this.assertImportItems(items);
const book = await this.freshBook(user.email);
const index = importDuplicateIndex(
book.contacts.map((entry) => entry.contact),
);
return {
items: items.map((item) => ({
uid: item.uid,
status:
book.contactsByUid.has(item.uid) || isExistingContact(index, item)
? 'exists'
: 'new',
})),
remaining: Math.max(0, MAX_CONTACTS - book.contacts.length),
};
}
/**
* POST /api/contact-imports — 가져온 연락처를 엔진 요청 하나로 만든다 (계약 3절 "가져오기" — 16단계).
* - 줄마다 화면이 준 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음 · 앞 시도가 들어감) 만들지 않고 그 연락처를 돌려준다 — 빠진 그룹만 마저 넣는다
* - 연락처 수 · 크기 한도는 묶음 전체로 본다 — 넘으면 CONTACT_002, 아무것도 만들지 않는다. 없는 그룹은 RES_001
* - 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고(skippedGroupIds) 만든다 — 실패가 아니다
* - 엔진이 일부만 받으면 받은 것만 돌려준다(빠진 uid 는 화면이 "넣지 못함"으로) — 던지지 않는다. 그룹 카드가 쓰는 uid 도 만들지 않는다(엔진이 받지 않는다)
* - 겹침(같은 이메일 등)은 보지 않는다 — 확인(checkImport)의 몫
*/
async importContacts(
actor: AuthenticatedUser,
items: readonly ContactImportItem[],
): Promise<ContactImportResult> {
this.assertImportItems(items);
const result = await this.change(actor, (book) => {
const groups = new Map<string, BookGroup>();
for (const item of items) {
for (const groupId of item.groupIds) {
if (!groups.has(groupId)) {
groups.set(groupId, this.requireGroup(book, groupId));
}
}
}
/** 이미 있는 같은 uid 의 연락처 */
const landed = new Map<string, BookContact>();
/** uid → 이 바꾸기 안의 key */
const keys = new Map<string, string>();
items.forEach((item, index) => {
const existing = book.contactsByUid.get(item.uid)?.[0];
if (existing) landed.set(item.uid, existing);
else if (!book.uidsInUse.has(item.uid)) {
keys.set(item.uid, `${IMPORT_KEY_PREFIX}${index}`);
}
});
if (book.contacts.length + keys.size > MAX_CONTACTS) {
throw overLimit('contacts-limit', {
contacts: book.contacts.length,
creating: keys.size,
});
}
const rooms = new Map<string, GroupRoom>(
[...groups].map(([id, group]) => [
id,
{
memberUids: new Set(group.card.memberUids),
memberCount: group.group.memberCount,
},
]),
);
const joins = planGroupJoins(
rooms,
items.filter((item) => landed.has(item.uid) || keys.has(item.uid)),
MAX_CONTACT_GROUP_MEMBERS,
);
const update: AddressBookCardUpdate[] = [];
for (const [groupId, group] of groups) {
const adding = joins.adds.get(groupId);
if (adding && adding.length > 0) {
update.push({
kind: 'group',
id: groupId,
memberUids: withMembers(group, adding),
});
}
}
return {
changes: {
create: items.flatMap((item): NewAddressBookCard[] => {
const key = keys.get(item.uid);
return key === undefined
? []
: [
{
kind: 'contact',
key,
uid: item.uid,
fields: fieldsOf(item),
},
];
}),
update,
destroy: [],
},
write: { allowPartialCreate: true },
result: (_after, written) => ({
items: items.flatMap((item) => {
const key = keys.get(item.uid);
const id =
landed.get(item.uid)?.card.id ??
(key === undefined ? undefined : written.createdIds.get(key));
return id === undefined
? []
: [
{
uid: item.uid,
id,
skippedGroupIds: [...(joins.skipped.get(item.uid) ?? [])],
},
];
}),
}),
};
});
const skipped = result.items.reduce(
(sum, item) => sum + item.skippedGroupIds.length,
0,
);
this.logger.log(
`${traceTag()} 연락처를 가져왔습니다: 보낸 ${items.length}명 가운데 ${result.items.length}명(이미 있던 같은 uid 포함), 넣지 못함 ${items.length - result.items.length}명, 구성원 한도로 그룹에 넣지 못함 ${skipped}건 (user=${maskEmail(normalizeUser(actor.email))})`,
);
return result;
}
/**
* POST /api/contact-deletions — 연락처 여러 개를 영구 삭제한다 (가져오기 [되돌리기] — 계약 3절 "가져오기" — 16단계). 되돌리기 표는 주지 않는다.
* 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로). 내 주소록에 없는 id(그룹 id 포함)는 notFoundIds.
* 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 상한을 넘으면 앞에서부터 나눠 쓴다 — 중간에 실패하면 앞 몫은 지워진 채다(다시 보내면 notFoundIds)
*/
async removeContacts(
actor: AuthenticatedUser,
ids: readonly string[],
): Promise<ContactsDeleted> {
if (ids.length < 1 || ids.length > MAX_CONTACT_DELETIONS) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-deletions-count', requested: ids.length },
});
}
if (new Set(ids).size !== ids.length) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-deletions-duplicate' },
});
}
const deleted = new Set<string>();
let pending: readonly string[] = ids;
while (pending.length > 0) {
const batch = pending;
/** 이 몫에서 지우려 했던 id — 엔진이 바쁘다고 했는데 들어갔으면 다음 시도에서 없어진 것을 지운 것으로 센다 */
const tried = new Set<string>();
const step = await this.change(actor, (book, attempt) => {
const present = batch.filter((id) => book.contactsById.has(id));
const landed = attempt.uncertain
? batch.filter((id) => tried.has(id) && !book.contactsById.has(id))
: [];
const groupIdsByUid = new Map<string, string[]>();
for (const group of book.groups) {
for (const uid of group.card.memberUids) {
const groupIds = groupIdsByUid.get(uid) ?? [];
groupIds.push(group.card.id);
groupIdsByUid.set(uid, groupIds);
}
}
const size = deletionChunkSize(
present.map((id) => {
const uid = book.contactsById.get(id)?.card.uid ?? null;
return uid === null ? [] : (groupIdsByUid.get(uid) ?? []);
}),
MAX_CARD_CHANGES_PER_WRITE,
);
const now = present.slice(0, size);
for (const id of now) tried.add(id);
const update = this.leavingGroups(book, now);
return {
changes: { create: [], update, destroy: now },
write: { withoutOriginals: true },
result: () => ({
deleted: [...landed, ...now],
rest: present.slice(size),
}),
log: `연락처를 한꺼번에 지웠습니다: ${now.length}명, 그룹에서 뺌 ${update.length}개`,
};
});
for (const id of step.deleted) deleted.add(id);
pending = step.rest;
}
return {
deletedIds: ids.filter((id) => deleted.has(id)),
notFoundIds: ids.filter((id) => !deleted.has(id)),
};
}
/**
* GET /api/contact-exports — 내 주소록 전체 또는 그룹 하나를 vCard 3.0 · CSV 로 (계약 3절 "내보내기" — 16단계).
* 이름 차례, 우리 칸만(contact-export.ts). 사내 목록은 넣지 않는다. 없는 그룹은 RES_001. 로그에는 형식 · 개수 · 그룹 id 만
*/
async exportContacts(
user: AuthenticatedUser,
format: ContactExportFormat,
groupId?: string,
): Promise<ContactExportFile> {
const book = await this.freshBook(user.email);
const group =
groupId === undefined ? null : this.requireGroup(book, groupId);
const entries =
group === null
? book.contacts
: group.memberIds.flatMap((id) => {
const entry = book.contactsById.get(id);
return entry ? [entry] : [];
});
const groupNames = new Map(
book.groups.map((entry) => [entry.card.id, entry.card.name]),
);
const contacts = entries.map(
(entry): ExportedContact => ({
...fieldsOf(entry.contact),
uid: entry.card.uid,
groupNames: entry.contact.groupIds.flatMap((id) => {
const name = groupNames.get(id);
return name ? [name] : [];
}),
}),
);
const content = Buffer.from(contactExportContent(format, contacts), 'utf8');
this.logger.log(
`${traceTag()} 주소록을 내보냈습니다: 형식=${format}, 연락처 ${contacts.length}명, 범위=${group === null ? '전체' : `그룹 ${group.card.id}`} (user=${maskEmail(normalizeUser(user.email))})`,
);
return {
fileName: contactExportFileName(
format,
group?.card.name ?? null,
new Date(this.now()),
),
contentType: contactExportContentType(format),
content,
count: contacts.length,
};
}
/**
* GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다.
* 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001
@@ -1071,6 +1358,7 @@ export class ContactsService {
user,
book.version,
planned.changes,
planned.write,
);
} catch (error) {
// 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다
@@ -1175,6 +1463,57 @@ export class ContactsService {
}
}
/**
* 가져오기 · 확인의 묶음 — 1~100명, 사람마다 연락처 추가와 같은 검사(이름이나 이메일 · 같은 주소), 묶음 안의 같은 uid 는 안 된다.
* 하나라도 걸리면 엔진에 묻기 전에 VAL_001 (화면이 같은 규칙으로 미리 거른다)
*/
private assertImportItems(items: readonly ContactImportItem[]): void {
if (items.length < 1 || items.length > MAX_CONTACT_IMPORT_BATCH) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-import-count', requested: items.length },
});
}
const uids = new Set<string>();
for (const item of items) {
this.assertNameOrEmail(item);
this.assertDistinctEmails(item.emails);
if (uids.has(item.uid)) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-import-duplicate-uid' },
});
}
uids.add(item.uid);
}
}
/**
* 연락처들을 지울 때 고칠 그룹 — 지우는 카드의 uid 를 구성원에서 뺀다. 같은 uid 의 다른 카드가 남으면(이번에 지우지 않는 카드) 그 uid 는 그대로 둔다.
* 그룹 구성원을 한 번 훑는다
*/
private leavingGroups(
book: BookView,
ids: readonly string[],
): AddressBookCardUpdate[] {
const removing = new Set(ids);
const uids = new Set<string>();
for (const id of ids) {
const uid = book.contactsById.get(id)?.card.uid ?? null;
if (uid === null) continue;
const owners = book.contactsByUid.get(uid) ?? [];
if (owners.every((owner) => removing.has(owner.card.id))) uids.add(uid);
}
if (uids.size === 0) return [];
return book.groups
.filter((group) => group.card.memberUids.some((uid) => uids.has(uid)))
.map(
(group): AddressBookCardUpdate => ({
kind: 'group',
id: group.card.id,
memberUids: withMembers(group, [], uids),
}),
);
}
/** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */
private assertWeight(book: BookView, changes: AddressBookChanges): void {
const after = weightAfterChanges(book, changes);
@@ -104,6 +104,70 @@ export interface ContactGroupMembersAdded {
readonly createdCount: number;
}
/**
* 가져오기의 한 사람 (계약 3절 ContactImportItem — 16단계) — 연락처 추가 본문(ContactInput)과 같은 칸 + 화면이 만든 uid.
* 같은 uid 가 이미 주소록에 있으면(다시 보낸 요청) 새로 만들지 않고 그 연락처를 돌려준다
*/
export interface ContactImportItem extends ContactInput {
/** `urn:uuid:` + 소문자 UUID */
readonly uid: string;
}
/** 가져오기 확인의 판정 — new 넣을 연락처 · exists 이미 있는 연락처(계약 3절 "가져오기") */
export type ContactImportStatus = 'new' | 'exists';
export const CONTACT_IMPORT_STATUSES: readonly ContactImportStatus[] = [
'new',
'exists',
];
/** POST /api/contact-import-checks 의 응답 */
export interface ContactImportCheck {
/** 보낸 차례 그대로 한 줄씩 */
readonly items: readonly {
readonly uid: string;
readonly status: ContactImportStatus;
}[];
/** 더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로는 내려가지 않는다) */
readonly remaining: number;
}
/** 가져온 연락처 하나 — 화면이 준 uid · 엔진의 카드 id · 구성원 500명이 차서 넣지 못한 그룹 */
export interface ContactImported {
readonly uid: string;
readonly id: string;
readonly skippedGroupIds: readonly string[];
}
/** POST /api/contact-imports 의 응답 — 넣은(또는 이미 있던 같은 uid 의) 연락처만, 보낸 차례 그대로. 빠진 uid 는 엔진이 받지 않은 것 */
export interface ContactImportResult {
readonly items: readonly ContactImported[];
}
/** POST /api/contact-deletions 의 응답 — 보낸 차례 그대로 */
export interface ContactsDeleted {
readonly deletedIds: readonly string[];
/** 내 주소록에 없는 id (이미 지움 · 그룹 id 포함) */
readonly notFoundIds: readonly string[];
}
/** 내보내기 형식 — vCard 3.0 · CSV(UTF-8 BOM) */
export type ContactExportFormat = 'vcard' | 'csv';
export const CONTACT_EXPORT_FORMATS: readonly ContactExportFormat[] = [
'vcard',
'csv',
];
/** 내보낼 파일 — 이름 · 형식 · 내용 */
export interface ContactExportFile {
readonly fileName: string;
readonly contentType: string;
readonly content: Buffer;
/** 담은 연락처 수 (로그) */
readonly count: number;
}
/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */
export type DirectoryEntryKind = 'person' | 'shared';
@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
import {
CONTACT_EXPORT_FORMATS,
type ContactExportFormat,
} from '@/modules/contacts/contacts.types';
import { ContactGroupIdQuery } from './contact.dto';
/** GET /api/contact-exports 의 쿼리 (계약 3절 "내보내기" — 16단계) — 모르는 형식 · 쿼리는 400 VAL_001 */
export class ContactExportQueryDto {
@ApiProperty({
description:
'vcard — vCard 3.0(.vcf, 다른 메일 · 휴대폰으로 옮길 때) · csv — CSV(UTF-8 BOM, 엑셀에서 볼 때)',
enum: [...CONTACT_EXPORT_FORMATS],
example: 'vcard',
})
@IsIn(CONTACT_EXPORT_FORMATS, {
message: '내보낼 형식을 다시 골라 주세요.',
})
format!: ContactExportFormat;
@ApiPropertyOptional({
description:
'이 그룹의 구성원만 — 없으면 내 주소록 전체. 없는 그룹이면 404 RES_001',
example: 'jy',
})
@ContactGroupIdQuery()
groupId?: string;
}
@@ -0,0 +1,175 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import { createValidationPipe } from '@/app.setup';
import {
MAX_CONTACT_DELETIONS,
MAX_CONTACT_IMPORT_BATCH,
} from '@/modules/contacts/contacts.constants';
import { ContactExportQueryDto } from './contact-export.dto';
import {
ContactDeletionDto,
ContactImportBatchDto,
} from './contact-import.dto';
/** 걸린 규칙 이름 — 중첩된 칸까지 */
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션으로 (stopAtFirstError 는 고른다 — 빼면 걸린 규칙을 모두 본다) */
async function namesOf<T extends object>(
type: new () => T,
body: object,
stopAtFirstError = false,
): Promise<string[]> {
const dto = plainToInstance(type, body);
return constraintNames(
await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
stopAtFirstError,
}),
);
}
const UID = 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const person = (overrides: object = {}) => ({
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: null,
groupIds: [],
uid: UID,
...overrides,
});
describe('가져오기 본문 (POST /api/contact-import-checks · /api/contact-imports — 16단계)', () => {
it('맞는 묶음 — 사람마다 연락처 추가 본문 + uid, 글은 앞뒤 공백을 지운다', async () => {
expect(
await namesOf(ContactImportBatchDto, { contacts: [person()] }),
).toEqual([]);
const dto = plainToInstance(ContactImportBatchDto, {
contacts: [person({ name: ' 김아라 ', company: ' ' })],
});
expect(await validate(dto)).toEqual([]);
expect(dto.contacts[0].name).toBe('김아라');
expect(dto.contacts[0].company).toBeNull();
});
it('uid — urn:uuid: + 소문자 UUID 만', async () => {
for (const uid of [
UID.toUpperCase(),
UID.replace('urn:uuid:', ''),
`${UID}0`,
'urn:uuid:not-a-uuid',
7,
null,
]) {
expect(
await namesOf(ContactImportBatchDto, { contacts: [person({ uid })] }),
).not.toEqual([]);
}
const missing: Record<string, unknown> = person();
delete missing.uid;
expect(
await namesOf(ContactImportBatchDto, { contacts: [missing] }),
).not.toEqual([]);
});
it(`묶음 — 배열 · 1~${MAX_CONTACT_IMPORT_BATCH}명, 사람마다 연락처 추가와 같은 칸 검사 · 모르는 칸은 안 된다`, async () => {
for (const contacts of [
null,
'x',
[],
Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () => person()),
[person({ emails: [{ address: 'not-an-email', label: 'work' }] })],
[person({ phones: [{ number: '내선 12', label: 'work' }] })],
[person({ note: '가'.repeat(1_001) })],
[person({ extra: true })],
]) {
expect(await namesOf(ContactImportBatchDto, { contacts })).not.toEqual(
[],
);
}
expect(
await namesOf(ContactImportBatchDto, {
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH }, () =>
person(),
),
}),
).toEqual([]);
});
it('개수가 넘으면 사람마다의 검사를 하지 않는다 — 형식 → 개수 → 중첩 차례 (stopAtFirstError)', async () => {
const names = await namesOf(
ContactImportBatchDto,
{
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () =>
person({ emails: [{ address: 'bad', label: 'work' }] }),
),
},
true,
);
expect(names).toEqual(['arrayMaxSize']);
const pipe = createValidationPipe();
await expect(
pipe.transform(
{ contacts: [] },
{ type: 'body', metatype: ContactImportBatchDto },
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('한꺼번에 지우기 본문 (POST /api/contact-deletions — 16단계)', () => {
it(`id 1~${MAX_CONTACT_DELETIONS}개 · id 모양 · 겹치지 않게`, async () => {
expect(await namesOf(ContactDeletionDto, { ids: ['jw', 'jx'] })).toEqual(
[],
);
for (const ids of [
null,
[],
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `c${n}`),
['jw', 'jw'],
['../x'],
[7],
]) {
expect(await namesOf(ContactDeletionDto, { ids })).not.toEqual([]);
}
expect(
await namesOf(ContactDeletionDto, {
ids: Array.from({ length: MAX_CONTACT_DELETIONS }, (_, n) => `c${n}`),
}),
).toEqual([]);
});
});
describe('내보내기 쿼리 (GET /api/contact-exports — 16단계)', () => {
it('format 은 vcard · csv 만(없으면 안 된다) · groupId 는 id 모양 · 모르는 쿼리는 안 된다', async () => {
expect(await namesOf(ContactExportQueryDto, { format: 'vcard' })).toEqual(
[],
);
expect(
await namesOf(ContactExportQueryDto, { format: 'csv', groupId: 'jy' }),
).toEqual([]);
for (const query of [
{},
{ format: 'xlsx' },
{ format: 'VCARD' },
{ format: ['vcard', 'csv'] },
{ format: 'csv', groupId: '../x' },
{ format: 'csv', limit: '10' },
]) {
expect(await namesOf(ContactExportQueryDto, query)).not.toEqual([]);
}
});
});
@@ -0,0 +1,202 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsString,
Matches,
ValidateNested,
} from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
CONTACT_ID_PATTERN,
CONTACT_IMPORT_UID_PATTERN,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_IMPORT_BATCH,
} from '@/modules/contacts/contacts.constants';
import {
CONTACT_IMPORT_STATUSES,
type ContactImportCheck,
type ContactImported,
type ContactImportItem,
type ContactImportResult,
type ContactImportStatus,
type ContactsDeleted,
} from '@/modules/contacts/contacts.types';
import { ContactInputDto } from './contact.dto';
/**
* 가져오기 · 한꺼번에 지우기 — 본문과 응답 (계약 3절 "가져오기 · 내보내기" — 16단계).
* 한 사람(ContactImportItem)은 연락처 추가 본문(ContactInputDto)과 같은 검사에 uid 를 더한 것이다. 이름이나 이메일 · 같은 연락처 안의 같은 주소 ·
* 묶음 안의 같은 uid 는 서비스가 본다(모두 400 VAL_001). 칸의 검사는 적은 차례대로 돈다(contact.dto.ts 머리말 — 형식 → 길이 · 개수 → 모양)
*/
/** `urn:uuid:` + 소문자 UUID 의 길이 */
const IMPORT_UID_LENGTH = 45;
function ImportUid(): PropertyDecorator {
return applyDecorators(
IsString({ message: '가져올 연락처의 표시가 올바르지 않습니다.' }),
MaxCodeUnits(IMPORT_UID_LENGTH, {
message: '가져올 연락처의 표시가 올바르지 않습니다.',
}),
Matches(CONTACT_IMPORT_UID_PATTERN, {
message: '가져올 연락처의 표시가 올바르지 않습니다.',
}),
);
}
/** 가져올 연락처 목록 — 형식 → 개수(1~100) → 사람마다(중첩) 차례로 (개수가 넘으면 중첩 검사를 하지 않는다 — stopAtFirstError) */
function ImportList(): PropertyDecorator {
return applyDecorators(
IsArray({ message: '가져올 연락처 목록 형식이 올바르지 않습니다.' }),
ArrayMinSize(1, { message: '가져올 연락처를 하나 이상 보내 주세요.' }),
ArrayMaxSize(MAX_CONTACT_IMPORT_BATCH, {
message: `한 번에 ${MAX_CONTACT_IMPORT_BATCH}명까지 보낼 수 있습니다.`,
}),
ValidateNested({ each: true }),
Type(() => ContactImportItemDto),
);
}
/** 지울 연락처 id 목록 — 형식 → 개수(1~500) → id 모양 → 겹침 */
function DeletionIdList(): PropertyDecorator {
return applyDecorators(
IsArray({ message: '지울 연락처 목록 형식이 올바르지 않습니다.' }),
ArrayMinSize(1, { message: '지울 연락처를 하나 이상 보내 주세요.' }),
ArrayMaxSize(MAX_CONTACT_DELETIONS, {
message: `한 번에 ${MAX_CONTACT_DELETIONS}명까지 지울 수 있습니다.`,
}),
Matches(CONTACT_ID_PATTERN, {
each: true,
message: '지울 연락처를 다시 골라 주세요.',
}),
ArrayDistinct({ message: '같은 연락처를 두 번 보낼 수 없습니다.' }),
);
}
export class ContactImportItemDto
extends ContactInputDto
implements ContactImportItem
{
@ApiProperty({
description:
'화면이 사람마다 만든 uid — `urn:uuid:` + 소문자 UUID. 엔진 카드의 uid 가 된다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않는다. 한 묶음 안에서 겹치면 400 VAL_001',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
maxLength: IMPORT_UID_LENGTH,
})
@ImportUid()
uid!: string;
}
/** 가져오기 · 확인의 본문 — 1~100명 */
export class ContactImportBatchDto {
@ApiProperty({
type: [ContactImportItemDto],
minItems: 1,
maxItems: MAX_CONTACT_IMPORT_BATCH,
description: `가져올 연락처 — 1~${MAX_CONTACT_IMPORT_BATCH}명. 칸 규칙은 연락처 추가(POST /api/contacts)와 같다`,
})
@ImportList()
contacts!: ContactImportItemDto[];
}
export class ContactImportCheckItemDto {
@ApiProperty({
description: '보낸 uid',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
uid!: string;
@ApiProperty({
description:
'new 넣을 연락처 · exists 이미 있는 연락처 — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있음, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있음, 또는 같은 uid 의 연락처가 있음',
enum: [...CONTACT_IMPORT_STATUSES],
example: 'new',
})
status!: ContactImportStatus;
}
/** POST /api/contact-import-checks → 200 */
export class ContactImportCheckDto implements ContactImportCheck {
@ApiProperty({
type: [ContactImportCheckItemDto],
description: '보낸 차례 그대로 한 줄씩',
})
items!: ContactImportCheckItemDto[];
@ApiProperty({
description:
'더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다). 화면은 이것으로 넣을 수를 미리 줄인다',
example: 2_660,
})
remaining!: number;
}
export class ContactImportedDto implements ContactImported {
@ApiProperty({
description: '보낸 uid',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
uid!: string;
@ApiProperty({
description:
'연락처 id — 새로 만든 카드, 또는 같은 uid 로 이미 있던 카드(다시 보낸 묶음)',
example: 'jw',
})
id!: string;
@ApiProperty({
type: [String],
description:
'구성원 500명이 차서 넣지 못한 그룹 id (보낸 groupIds 차례) — 실패가 아니다. 연락처는 만들었고 나머지 그룹에는 넣었다',
example: [],
})
skippedGroupIds!: string[];
}
/** POST /api/contact-imports → 201 */
export class ContactImportResultDto implements ContactImportResult {
@ApiProperty({
type: [ContactImportedDto],
description:
'넣은 연락처(같은 uid 로 이미 있던 것 포함) — 보낸 차례 그대로. 보낸 uid 가 빠져 있으면 메일 서버가 받지 않은 것이다(화면은 "넣지 못함"으로)',
})
items!: ContactImportedDto[];
}
/** POST /api/contact-deletions 의 본문 — 1~500개 · 겹치지 않게 */
export class ContactDeletionDto {
@ApiProperty({
type: [String],
minItems: 1,
maxItems: MAX_CONTACT_DELETIONS,
description: `지울 연락처 id — 1~${MAX_CONTACT_DELETIONS}개, 겹치지 않게(겹치면 400 VAL_001)`,
example: ['jw', 'jx'],
})
@DeletionIdList()
ids!: string[];
}
/** POST /api/contact-deletions → 200 */
export class ContactsDeletedDto implements ContactsDeleted {
@ApiProperty({
type: [String],
description: '지운 연락처 id — 보낸 차례 그대로',
example: ['jw'],
})
deletedIds!: string[];
@ApiProperty({
type: [String],
description:
'내 주소록에 없는 id — 이미 지움 · 그룹 id 등 (보낸 차례 그대로)',
example: ['jx'],
})
notFoundIds!: string[];
}
@@ -0,0 +1,121 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { SIGNATURE_NAME_MAX_LENGTH } from '@/modules/mail-signature/mail-signature.constants';
import {
MailSignatureDefaultsDto,
SaveMailSignatureDto,
UpdateLegacyMailSignatureDto,
} from './mail-signature.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) — 걸린 규칙 이름만 */
async function constraintsOf(
type: new () => object,
body: object,
): Promise<string[]> {
const errors = await validate(plainToInstance(type, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('서명 만들기 · 고치기 본문 (POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 16단계)', () => {
const save = (body: object) => constraintsOf(SaveMailSignatureDto, body);
it('이름은 앞뒤 공백을 지운 값으로 1~30자(JS 문자열 길이) — 본문 칸은 보내지 않거나 null 이어도 된다', async () => {
expect(await save({ name: '대외' })).toEqual([]);
expect(
await save({ name: '대외', bodyHtml: '<p>김아라</p>', body: '김아라' }),
).toEqual([]);
expect(await save({ name: '대외', bodyHtml: null, body: null })).toEqual(
[],
);
expect(await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH) })).toEqual(
[],
);
expect(
plainToInstance(SaveMailSignatureDto, { name: ' 대외 ' }).name,
).toBe('대외');
// 이모지 하나는 2자로 센다 — 화면의 .length 와 같다
expect(
await save({ name: '🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2) }),
).toEqual([]);
});
it('이름이 비거나(공백뿐 포함) · 30자를 넘거나 · 제어 문자 · 글이 아니면 VAL_001', async () => {
expect(await save({ name: '' })).toEqual(['minCodeUnits']);
expect(await save({ name: ' ' })).toEqual(['minCodeUnits']);
expect(
await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
expect(
await save({ name: `${'🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2)}x` }),
).toEqual(['maxCodeUnits']);
for (const name of ['사내\n영문', '사내\t영문', 'a\u0000']) {
expect(await save({ name })).toEqual(['matches']);
}
// 앞뒤의 공백 · 줄바꿈은 지운 값으로 본다(trim)
expect(await save({ name: '\t사내\n' })).toEqual([]);
expect(await save({})).toEqual(expect.arrayContaining(['isString']));
expect(await save({ name: 1 })).toEqual(
expect.arrayContaining(['isString']),
);
});
it('본문 칸이 글이 아니거나 모르는 칸(옛 두 표시 포함)이면 VAL_001', async () => {
expect(await save({ name: '대외', bodyHtml: 1 })).toEqual(['isString']);
expect(await save({ name: '대외', body: [] })).toEqual(['isString']);
expect(await save({ name: '대외', isForNew: true })).toEqual([
'whitelistValidation',
]);
});
});
describe('메일에 넣을 서명 (PUT /api/mail-signature-defaults — 16단계)', () => {
const defaults = (body: object) =>
constraintsOf(MailSignatureDefaultsDto, body);
it('두 키를 모두 보낸다 — 서명 id 또는 null(넣지 않음). 모양은 서비스가 본다(내 서명이 아니면 404)', async () => {
const id = '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d';
expect(await defaults({ newMessageId: id, replyId: null })).toEqual([]);
expect(await defaults({ newMessageId: null, replyId: null })).toEqual([]);
expect(await defaults({ newMessageId: 'not-a-uuid', replyId: id })).toEqual(
[],
);
});
it('빠진 키 · 글이 아닌 값 · 너무 긴 값 · 모르는 칸은 VAL_001', async () => {
expect(await defaults({ newMessageId: null })).toEqual(
expect.arrayContaining(['isString']),
);
expect(await defaults({ newMessageId: 1, replyId: null })).toEqual(
expect.arrayContaining(['isString']),
);
expect(
await defaults({ newMessageId: 'x'.repeat(65), replyId: null }),
).toEqual(['maxCodeUnits']);
expect(
await defaults({ newMessageId: null, replyId: null, forwardId: null }),
).toEqual(['whitelistValidation']);
});
});
describe('옛 경로 저장 본문 (PUT /api/mail-signature — 16단계 전의 웹 탭)', () => {
it('지금과 같은 네 칸 — 두 표시는 boolean 이어야 한다', async () => {
expect(
await constraintsOf(UpdateLegacyMailSignatureDto, {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
}),
).toEqual([]);
expect(
await constraintsOf(UpdateLegacyMailSignatureDto, {
isForNew: 'true',
isForReply: true,
}),
).toEqual(['isBoolean']);
});
});
@@ -1,17 +1,35 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsOptional,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/modules/mail-signature/mail-signature.constants';
import type {
LegacyMailSignature,
MailSignature,
MailSignatureDefaults,
SignatureImage,
} from '@/modules/mail-signature/mail-signature.types';
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants';
const IMAGE_TYPES: SignatureImageType[] = [
'image/png',
@@ -19,24 +37,109 @@ const IMAGE_TYPES: SignatureImageType[] = [
'image/gif',
];
/** PUT /api/mail-signature — 계약 2절 "서명" */
export class UpdateMailSignatureDto {
/** 서명 id 칸의 길이 상한 — 서버가 만든 UUID(36자)라 넉넉히. 넘거나 형식이 틀린 id 는 없는 서명과 같다(404 RES_001) */
const SIGNATURE_ID_MAX_LENGTH = 64;
/** null 은 "넣지 않음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 두 키를 모두 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
const BODY_HTML_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·빈 문자열이면 서식 서명 없음. ` +
'어느 서명도 더는 가리키지 않는 그림은 저장할 때 지운다';
const BODY_HTML_EXAMPLE =
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">';
const BODY_DESCRIPTION = `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`;
/** 서명 이름 — 앞뒤 공백을 뺀 1~30자(JS 문자열 길이), 제어 문자 없음. 검사는 적은 차례대로(형식 → 길이 → 모양) */
function SignatureName() {
return applyDecorators(
ApiProperty({
description:
`서명 이름 — 앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자(JS 문자열 길이), 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. ` +
'내 다른 서명과 같은 이름(영문 대소문자 무시)이면 400 VAL_001',
example: '대외',
minLength: 1,
maxLength: SIGNATURE_NAME_MAX_LENGTH,
}),
TrimString(),
IsString({ message: '서명 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '서명 이름을 입력해 주세요.' }),
MaxCodeUnits(SIGNATURE_NAME_MAX_LENGTH, {
message: `서명 이름은 ${SIGNATURE_NAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
}),
Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '서명 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
}),
);
}
/** 「메일에 넣을 서명」 칸 하나 — 내 서명 id 또는 null(넣지 않음). 키는 늘 보낸다 */
function DefaultSignatureId(description: string) {
return applyDecorators(
ApiProperty({
description: `${description} — 내 서명 id, null 이면 넣지 않음. 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 404 RES_001`,
nullable: true,
type: String,
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
}),
ValidateIf(unlessNull),
IsString({ message: '서명을 다시 골라 주세요.' }),
MaxCodeUnits(SIGNATURE_ID_MAX_LENGTH, {
message: '서명을 다시 골라 주세요.',
}),
);
}
/** POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 계약 2절 "서명" (16단계, 전체 교체) */
export class SaveMailSignatureDto {
@SignatureName()
name!: string;
@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 이 더는 가리키지 않는 그림은 저장할 때 지운다',
description: BODY_HTML_DESCRIPTION,
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="">',
example: BODY_HTML_EXAMPLE,
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
description: BODY_DESCRIPTION,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
})
@IsOptional()
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
}
/** GET · PUT /api/mail-signature-defaults — 계약 2절 "서명"의 「메일에 넣을 서명」 (16단계) */
export class MailSignatureDefaultsDto implements MailSignatureDefaults {
@DefaultSignatureId('새 메일에 넣는 서명')
newMessageId!: string | null;
@DefaultSignatureId('답장 · 전달에 넣는 서명(인용 위)')
replyId!: string | null;
}
/** PUT /api/mail-signature — 옛 경로(16단계 전의 웹 탭). 「새 메일」 기본 서명의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다 */
export class UpdateLegacyMailSignatureDto {
@ApiPropertyOptional({
description: BODY_HTML_DESCRIPTION,
nullable: true,
type: String,
example: BODY_HTML_EXAMPLE,
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: BODY_DESCRIPTION,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
@@ -45,11 +148,19 @@ export class UpdateMailSignatureDto {
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
@ApiProperty({ description: '새 메일에 넣는다', example: true })
@ApiProperty({
description:
'true = 이 서명을 새 메일의 기본으로, false = 새 메일에 넣지 않음',
example: true,
})
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
isForNew!: boolean;
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
@ApiProperty({
description:
'true = 이 서명을 답장·전달의 기본으로, false = 이 서명이 기본이었으면 넣지 않음(다른 서명이 기본이면 그대로)',
example: true,
})
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
isForReply!: boolean;
}
@@ -82,26 +193,86 @@ export class SignatureImageDto implements SignatureImage {
height!: number;
}
/** 서명 하나 (16단계) */
export class MailSignatureDto implements MailSignature {
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
description: '서명 id (서버가 만든 UUID)',
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
})
id!: string;
@ApiProperty({ description: '서명 이름 (1~30자)', example: '대외' })
name!: string;
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 없으면 null`,
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
description: '텍스트 서명 — 없으면 null',
nullable: true,
type: String,
})
body!: string | null;
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
@ApiProperty({
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
type: [SignatureImageDto],
})
images!: SignatureImageDto[];
@ApiProperty({
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
example: '2026-11-10T05:12:00.000Z',
})
updatedAt!: string;
}
/** GET /api/mail-signatures — 내 서명 목록 */
export class MailSignaturePageDto {
@ApiProperty({
type: [MailSignatureDto],
description: `만든 차례 — ${SIGNATURES_MAX}개까지`,
})
items!: MailSignatureDto[];
@ApiProperty({
type: PageInfoDto,
description: '한 번에 모두 내려준다 (hasNext=false, total = 서명 수)',
})
pageInfo!: PageInfoDto;
}
/** GET · PUT /api/mail-signature — 옛 경로의 서명 (앱 첫 판 · 16단계 전의 웹 탭) */
export class LegacyMailSignatureDto implements LegacyMailSignature {
@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:
'「새 메일」 기본 서명이 있다 — 없으면 false (그때 본문이 있으면 「답장 · 전달」 기본 서명이다)',
example: true,
})
isForNew!: boolean;
@ApiProperty({
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
description:
'「답장 · 전달」 기본이 이 서명이다 — 「새 메일」 기본과 다른 서명이면 false (앱 첫 판은 그때 답장에 서명을 넣지 않는다)',
example: true,
})
isForReply!: boolean;
@@ -113,7 +284,7 @@ export class MailSignatureDto implements MailSignature {
images!: SignatureImageDto[];
@ApiProperty({
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
description: '저장한 시각 (ISO 8601 UTC) — 기본 서명이 둘 다 없으면 null',
nullable: true,
type: String,
example: '2026-10-27T05:12:00.000Z',
@@ -20,15 +20,28 @@ export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
*/
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(POST · PUT)이 400 VAL_001 */
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
/**
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
* 한 사람이 둘 수 있는 서명 그림 수 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 서명 여럿(16단계 — 10개 × 3장)이 함께 쓰는 칸이라 10 → 20 으로 늘렸다. 편집기에서 그림을 넣었다 빼기를 되풀이해도
* (빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 하나의 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 어느 서명도 더는 가리키지 않는 그림은 다음 저장이 지운다.
*/
export const SIGNATURE_IMAGES_STORED_MAX = 10;
export const SIGNATURE_IMAGES_STORED_MAX = 20;
/** 한 사람이 둘 수 있는 서명 수 (16단계) — 11번째 만들기는 400 VAL_001 */
export const SIGNATURES_MAX = 10;
/** 서명 이름의 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (16단계). 같은 이름(ASCII 대소문자 무시)은 400 VAL_001 */
export const SIGNATURE_NAME_MAX_LENGTH = 30;
/**
* 옛 서명(한 사람에 하나)을 옮긴 이름 — 마이그레이션 `MultipleMailSignatures` 와 옛 경로 `PUT /api/mail-signature` 가
* 기본 서명(「새 메일」 · 「답장 · 전달」)이 둘 다 없을 때 만드는 서명의 이름이다 (계약 2절 "서명" — 16단계)
*/
export const LEGACY_SIGNATURE_NAME = '기본 서명';
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
@@ -37,9 +37,9 @@ 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,
LegacyMailSignatureDto,
SignatureImageDto,
UpdateMailSignatureDto,
UpdateLegacyMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURE_HTML_MAX_LENGTH,
@@ -57,7 +57,8 @@ import { SignatureImageIdPipe } from './signature-image-id.pipe';
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
// 16단계부터 서명은 여럿이다(mail-signatures.controller.ts) — 여기의 GET · PUT 은 「새 메일」 기본 서명(없으면 「답장 · 전달」 기본)을 다루는 옛 경로(앱 첫 판 · 옛 웹 탭)이고,
// 그림 경로는 모든 서명이 함께 쓴다. 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@@ -68,33 +69,37 @@ export class MailSignatureController {
@Get()
@ApiOperation({
summary: '내 서명',
summary: '「새 메일」 기본 서명 — 옛 경로 (앱 첫 판)',
description:
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
'16단계부터 서명은 여럿이다(/api/mail-signatures). 이 경로는 「새 메일」 기본 서명을 15단계까지의 모양으로 준다 — ' +
'isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 같은 서명. ' +
'「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 isForNew false · isForReply true 로 준다. ' +
'둘 다 없으면 { bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }.',
})
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
@ApiStandardResponse(LegacyMailSignatureDto, { description: '서명' })
getSignature(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getSignature(user.email);
return this.signatures.getLegacySignature(user.email);
}
@Put()
@ApiOperation({
summary: '서명 저장 (전체 교체)',
summary: '「새 메일」 기본 서명 저장 — 옛 경로 (16단계 전의 웹 탭)',
description:
'옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 없으면 만든다)의 본문을 바꾸고, isForNew · isForReply 를 「메일에 넣을 서명」 둘로 옮긴다. ' +
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
'어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
@ApiStandardResponse(LegacyMailSignatureDto, { description: '저장한 서명' })
@ApiErrorResponse(
'VAL_001',
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 서명을 새로 만들어야 하는데 이미 10개`,
)
saveSignature(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateMailSignatureDto,
@Body() dto: UpdateLegacyMailSignatureDto,
) {
return this.signatures.saveSignature(user.email, dto);
return this.signatures.saveLegacySignature(user.email, dto);
}
@Post('images')
@@ -103,8 +108,8 @@ export class MailSignatureController {
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 이 본다). ` +
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — 서명 저장이 본다). ` +
'서명을 저장하지 않으면 하루 뒤 지운다.',
})
@ApiConsumes('multipart/form-data')
@@ -1,35 +1,63 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/**
* 서명 이름 칸 — 이름은 30자(JS 문자열 길이 — SIGNATURE_NAME_MAX_LENGTH)까지라 넉넉히 둔다.
* varchar 는 코드 포인트로 세어 JS 길이 30 이면 늘 들어간다 (서로게이트 쌍 하나가 JS 로는 2자 · 여기서는 1자)
*/
const NAME_COLUMN_LENGTH = 64;
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지)다. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다. HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
*
* 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시로 둔다 — 한 사람에 하나씩만(부분 유일 인덱스).
* 서명을 지우면 표시도 함께 없어져 그 자리는 "넣지 않음"이 된다. 퇴사 마무리는 주소로 이 표의 줄을 지운다(modules/ops/ops-data.service.ts).
* 목록은 만든 차례(created_at, 같으면 id)다.
*/
@Entity({ name: 'mail_signatures' })
@Index('IDX_mail_signatures_user_email_created_at', ['userEmail', 'createdAt'])
@Index('UQ_mail_signatures_new_message_default', ['userEmail'], {
unique: true,
where: '"is_new_message_default"',
})
@Index('UQ_mail_signatures_reply_default', ['userEmail'], {
unique: true,
where: '"is_reply_default"',
})
export class MailSignatureEntity {
@PrimaryColumn({
/** 서버가 만든 UUID (소문자) */
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({
name: 'user_email',
type: 'varchar',
length: EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시 — 서비스가 사용자 잠금 안에서 본다) */
@Column({ type: 'varchar', length: NAME_COLUMN_LENGTH })
name!: 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_new_message_default', type: 'boolean', default: false })
isNewMessageDefault!: boolean;
/** 답장·전달에 넣는다 (인용 위) */
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
isForReply!: boolean;
/** 「답장 · 전달」에 넣는 서명(인용 위) — 한 사람에 하나까지 */
@Column({ name: 'is_reply_default', type: 'boolean', default: false })
isReplyDefault!: boolean;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@@ -12,11 +12,15 @@ 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 {
MailSignatureDefaultsController,
MailSignaturesController,
} from './mail-signatures.controller';
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다. 16단계부터 한 사람에 서명 여러 개(10개까지).
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
*
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
@@ -47,7 +51,13 @@ import { SignatureImageStorage } from './signature-image-storage';
},
}),
],
controllers: [MailSignatureController],
controllers: [
// 여러 서명 · 「메일에 넣을 서명」 둘 (16단계)
MailSignaturesController,
MailSignatureDefaultsController,
// 옛 경로(「새 메일」 기본 서명 — 앱 첫 판) · 서명 그림
MailSignatureController,
],
providers: [
MailSignatureRepository,
{
@@ -1,36 +1,75 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
import {
DataSource,
In,
IsNull,
LessThan,
type EntityManager,
type Repository,
} from 'typeorm';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureEntity } from './mail-signature.entity';
import type { MailSignatureDefaults } from './mail-signature.types';
/**
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
* 서명 줄이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
*/
const SIGNATURE_SAVE_LOCK = 0x53494731;
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
export type PrepareSignature = (
images: readonly MailSignatureImageEntity[],
) => {
readonly signature: MailSignatureEntity;
readonly referencedImageIds: readonly string[];
};
/** 목록 차례 — 만든 차례, 같은 시각이면 id */
const SIGNATURE_ORDER = { createdAt: 'ASC', id: 'ASC' } as const;
/** 서명 저장 결과 */
export interface SavedSignature {
readonly signature: MailSignatureEntity;
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
/** 잠근 뒤 본 그 사람의 서명 · 그림 */
export interface SignatureSnapshot {
/** 만든 차례 */
readonly signatures: readonly MailSignatureEntity[];
/** 올린 차례 */
readonly images: readonly MailSignatureImageEntity[];
}
/** 새로 넣을 서명 — 기본 표시는 plan 의 defaults 로만 바꾼다(넣을 때는 늘 꺼짐) */
export type NewSignatureRow = Omit<
MailSignatureEntity,
'isNewMessageDefault' | 'isReplyDefault'
>;
/** 고칠 서명 — 이름 · 본문 · 저장 시각만 */
export type SignatureRowUpdate = Pick<
MailSignatureEntity,
'id' | 'name' | 'bodyHtml' | 'bodyText' | 'updatedAt'
>;
/** 잠근 뒤 정한 바꿀 것 — 서비스가 스냅숏을 보고 정한다(한도 · 이름 겹침은 서비스가 던진다 — 그러면 아무것도 바꾸지 않는다) */
export interface SignaturePlan {
readonly insert?: NewSignatureRow;
readonly update?: SignatureRowUpdate;
readonly removeId?: string;
/** 「메일에 넣을 서명」 둘을 이 값으로 — 없으면 그대로 (지운 서명의 표시는 줄과 함께 없어진다) */
readonly defaults?: MailSignatureDefaults;
/** 바꾼 뒤 모든 서명 HTML 이 가리키는 그림 (합집합) — 이 밖의 그림은 지운다(막 올린 것은 남긴다) */
readonly referencedImageIds: readonly string[];
}
export type PlanSignatureChange = (
snapshot: SignatureSnapshot,
) => SignaturePlan;
/** 서명 바꾸기 결과 */
export interface SignatureChangeResult {
/** 바꾼 뒤 그 사람의 서명 — 만든 차례, 기본 표시 포함 */
readonly signatures: MailSignatureEntity[];
/** 바꾼 뒤 남은 그 사람의 그림 (올린 차례) */
readonly images: MailSignatureImageEntity[];
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
readonly removed: MailSignatureImageEntity[];
}
/**
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
* 서명·서명 그림 표 접근 — 판단(정화·한도·이름 겹침·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 서명 · 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 id 로는 아무것도 나오지 않는다.
*/
@Injectable()
export class MailSignatureRepository {
@@ -42,8 +81,12 @@ export class MailSignatureRepository {
private readonly dataSource: DataSource,
) {}
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
return this.signatures.findOneBy({ userEmail });
/** 그 사람의 서명 — 만든 차례 */
listSignatures(userEmail: string): Promise<MailSignatureEntity[]> {
return this.signatures.find({
where: { userEmail },
order: SIGNATURE_ORDER,
});
}
/** 그 사람의 그림 — 올린 순서대로 */
@@ -70,31 +113,62 @@ export class MailSignatureRepository {
}
/**
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 서명을 바꾼다(만들기 · 고치기 · 지우기 · 기본 바꾸기) — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 서명과 그림을 읽는다(그림은 FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `plan` 이 바꿀 것을 정한다
* 4) 넣기 · 고치기 · 지우기 → 기본 표시(끄고 나서 켠다 — 부분 유일 인덱스는 줄마다 바로 보므로) →
* 모든 서명이 가리키는 그림은 붙은 것으로 표시하고, 어느 서명도 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
* 5) 바꾼 뒤의 서명을 다시 읽어 돌려준다
*/
saveSignature(
change(
userEmail: string,
prepare: PrepareSignature,
plan: PlanSignatureChange,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
): Promise<SignatureChangeResult> {
return this.dataSource.transaction(async (manager) => {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
SIGNATURE_SAVE_LOCK,
userEmail,
]);
const signatures = await manager.find(MailSignatureEntity, {
where: { userEmail },
order: SIGNATURE_ORDER,
});
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 next = plan({ signatures, images });
const referenced = new Set(referencedImageIds);
if (next.insert) {
await manager.insert(MailSignatureEntity, {
...next.insert,
userEmail,
isNewMessageDefault: false,
isReplyDefault: false,
});
}
if (next.update) {
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
await manager.update(
MailSignatureEntity,
{ id, userEmail },
{ name, bodyHtml, bodyText, updatedAt },
);
}
if (next.removeId) {
await manager.delete(MailSignatureEntity, {
id: next.removeId,
userEmail,
});
}
if (next.defaults) {
await this.applyDefaults(manager, userEmail, next.defaults);
}
const referenced = new Set(next.referencedImageIds);
const attach = images
.filter(
(image) => referenced.has(image.id) && image.attachedAt === null,
@@ -105,7 +179,6 @@ export class MailSignatureRepository {
!referenced.has(image.id) &&
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
);
if (attach.length > 0) {
await manager.update(
MailSignatureImageEntity,
@@ -118,9 +191,13 @@ export class MailSignatureRepository {
id: In(stale.map((image) => image.id)),
});
}
const attached = new Set(attach);
return {
signature,
signatures: await manager.find(MailSignatureEntity, {
where: { userEmail },
order: SIGNATURE_ORDER,
}),
images: images
.filter((image) => !stale.includes(image))
.map((image) =>
@@ -131,6 +208,33 @@ export class MailSignatureRepository {
});
}
/**
* 기본 표시를 그 값으로 — 칸마다 먼저 다른 줄의 표시를 끄고 그 줄을 켠다(한 문장으로 바꾸면 줄마다 보는 유일 인덱스에 걸릴 수 있다).
* id 는 서비스가 그 사람의 서명인지 본 값이다 (주인 조건도 함께 건다)
*/
private async applyDefaults(
manager: EntityManager,
userEmail: string,
defaults: MailSignatureDefaults,
): Promise<void> {
const columns = [
['is_new_message_default', defaults.newMessageId],
['is_reply_default', defaults.replyId],
] as const;
for (const [column, id] of columns) {
await manager.query(
`UPDATE "mail_signatures" SET "${column}" = false WHERE "user_email" = $1 AND "${column}" AND "id" IS DISTINCT FROM $2::uuid`,
[userEmail, id],
);
if (id !== null) {
await manager.query(
`UPDATE "mail_signatures" SET "${column}" = true WHERE "user_email" = $1 AND "id" = $2::uuid AND NOT "${column}"`,
[userEmail, id],
);
}
}
}
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
return this.images.find({
@@ -8,6 +8,8 @@ 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 {
LEGACY_SIGNATURE_NAME,
SIGNATURES_MAX,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
@@ -17,34 +19,37 @@ import {
import { MailSignatureEntity } from './mail-signature.entity';
import type {
MailSignatureRepository,
PrepareSignature,
SavedSignature,
PlanSignatureChange,
SignatureChangeResult,
SignaturePlan,
} from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import type { MailSignatureDefaults } from './mail-signature.types';
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}`;
const NOT_MINE = '11111111-1111-4111-8111-111111111111';
/**
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건 · 기본 표시 · 바꾼 뒤 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션 · 사용자 잠금 · 부분 유일 인덱스)은 e2e 가 본다.
*/
class FakeRepository {
readonly signatures = new Map<string, MailSignatureEntity>();
signatures: MailSignatureEntity[] = [];
images: MailSignatureImageEntity[] = [];
/** 다음 insertImage 를 실패시킨다 */
failNextInsert = false;
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextSave: string | null = null;
readonly saveCalls: {
referenced: readonly string[];
/** 다음 바꾸기가 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextChange: string | null = null;
readonly changeCalls: {
plan: SignaturePlan;
keepUploadedAfter: Date;
}[] = [];
findSignature(user: string): Promise<MailSignatureEntity | null> {
return Promise.resolve(this.signatures.get(user) ?? null);
listSignatures(user: string): Promise<MailSignatureEntity[]> {
return Promise.resolve(this.mine(user).map((row) => ({ ...row })));
}
listImages(user: string): Promise<MailSignatureImageEntity[]> {
@@ -76,38 +81,91 @@ class FakeRepository {
return Promise.resolve();
}
saveSignature(
change(
user: string,
prepare: PrepareSignature,
plan: PlanSignatureChange,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
if (this.deletedBeforeNextSave) {
const gone = this.deletedBeforeNextSave;
this.deletedBeforeNextSave = null;
): Promise<SignatureChangeResult> {
if (this.deletedBeforeNextChange) {
const gone = this.deletedBeforeNextChange;
this.deletedBeforeNextChange = 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) {
const images = this.images.filter((row) => row.userEmail === user);
let next: SignaturePlan;
try {
next = plan({
signatures: this.mine(user).map((row) => ({ ...row })),
images,
});
} catch (error) {
// 정한 것을 던지면(한도 · 이름 겹침) 아무것도 바꾸지 않는다 — 실제 표는 트랜잭션을 되돌린다
return Promise.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
this.changeCalls.push({ plan: next, keepUploadedAfter });
if (next.insert) {
this.signatures.push({
...next.insert,
isNewMessageDefault: false,
isReplyDefault: false,
});
}
if (next.update) {
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
this.signatures = this.signatures.map((row) =>
row.id === id && row.userEmail === user
? { ...row, name, bodyHtml, bodyText, updatedAt }
: row,
);
}
if (next.removeId) {
this.signatures = this.signatures.filter(
(row) => !(row.id === next.removeId && row.userEmail === user),
);
}
if (next.defaults) this.applyDefaults(user, next.defaults);
const referenced = new Set(next.referencedImageIds);
for (const row of images) {
if (referenced.has(row.id) && row.attachedAt === null) {
row.attachedAt = now;
}
}
const stale = mine.filter(
const stale = images.filter(
(row) =>
!referenced.includes(row.id) &&
!referenced.has(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)),
signatures: this.mine(user).map((row) => ({ ...row })),
images: images.filter((row) => !stale.includes(row)),
removed: stale,
});
}
/** 만든 차례 (서비스가 사람마다 만든 시각을 늘 늘려 준다) */
private mine(user: string): MailSignatureEntity[] {
return this.signatures
.filter((row) => row.userEmail === user)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
private applyDefaults(user: string, defaults: MailSignatureDefaults): void {
this.signatures = this.signatures.map((row) =>
row.userEmail === user
? {
...row,
isNewMessageDefault: row.id === defaults.newMessageId,
isReplyDefault: row.id === defaults.replyId,
}
: row,
);
}
}
describe('MailSignatureService', () => {
@@ -139,7 +197,7 @@ describe('MailSignatureService', () => {
): Promise<MailSignatureImageEntity> {
const id =
overrides.id ??
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
`${String(repository.images.length).padStart(8, '0')}-0000-4000-8000-000000000000`;
const row: MailSignatureImageEntity = {
id,
userEmail: ARA,
@@ -157,6 +215,10 @@ describe('MailSignatureService', () => {
return row;
}
/** 서명 하나를 만든다 (이름 · 텍스트만) */
const create = (name: string, extra: { bodyHtml?: string | null } = {}) =>
service.createSignature(ARA, { name, body: `${name} 서명`, ...extra });
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
repository = new FakeRepository();
@@ -175,110 +237,269 @@ describe('MailSignatureService', () => {
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('여러 서명 (16단계)', () => {
it('서명이 없으면 빈 목록 · 기본 둘 null — 첫 서명은 두 곳의 기본이 되고, 다음 서명은 기본을 바꾸지 않는다', async () => {
await expect(service.listSignatures(ARA)).resolves.toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: null,
});
const first = await create('사내');
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: first.id,
replyId: first.id,
});
const second = await create('대외');
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: first.id,
replyId: first.id,
});
const list = await service.listSignatures(` ${ARA.toUpperCase()} `);
expect(list.items.map((item) => [item.id, item.name])).toEqual([
[first.id, '사내'],
[second.id, '대외'],
]);
expect(list.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 2,
});
// 다른 사람의 서명은 따로다 — 그 사람의 첫 서명도 그 사람의 기본이 된다
const doyun = await service.createSignature(DOYUN, { name: '사내' });
await expect(service.getDefaults(DOYUN)).resolves.toEqual({
newMessageId: doyun.id,
replyId: doyun.id,
});
});
});
describe('저장', () => {
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
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 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(NOT_MINE)}">`;
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
const saved = await service.createSignature(ARA, {
name: '대외',
bodyHtml: html,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
});
expect(saved).toEqual({
id: expect.stringMatching(/^[0-9a-f-]{36}$/) as string,
name: '대외',
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);
expect(repository.changeCalls[0].plan.referencedImageIds).toEqual([
mine.id,
]);
expect((await service.listSignatures(ARA)).items).toEqual([saved]);
});
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
it(`${SIGNATURES_MAX}개까지 — 11번째는 400 VAL_001, 같은 이름(영문 대소문자 무시)도 VAL_001 — 아무것도 저장하지 않는다`, async () => {
await create('Office');
expect(await failureOf(() => create('office'))).toEqual({
code: 'VAL_001',
reason: 'signature-name-taken',
});
// 영문 밖의 글자는 대소문자를 가리지 않고 그대로 견준다
await expect(create('ÖFFICE')).resolves.toMatchObject({ name: 'ÖFFICE' });
for (let index = 3; index <= SIGNATURES_MAX; index += 1) {
await create(`서명 ${index}`);
}
const before = repository.signatures.length;
expect(await failureOf(() => create('열한 번째'))).toEqual({
code: 'VAL_001',
reason: 'too-many-signatures',
});
expect(repository.signatures).toHaveLength(before);
});
await service.saveSignature(ARA, {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
it('고치기는 이름 · 본문을 바꾸고 기본 표시는 그대로 — 다른 서명과 같은 이름은 VAL_001, 자기 이름(대소문자만 바꿈)은 된다', async () => {
const a = await create('사내');
const b = await create('대외');
const updated = await service.updateSignature(ARA, a.id.toUpperCase(), {
name: '사내 (새)',
body: '바꾼 서명',
bodyHtml: '<p>바꾼 서명</p>',
});
expect(updated).toMatchObject({
id: a.id,
name: '사내 (새)',
body: '바꾼 서명',
bodyHtml: '<p>바꾼 서명</p>',
});
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: a.id,
});
const { keepUploadedAfter } = repository.saveCalls[0];
expect(
await failureOf(() =>
service.updateSignature(ARA, b.id, { name: '사내 (새)' }),
),
).toEqual({ code: 'VAL_001', reason: 'signature-name-taken' });
await expect(
service.updateSignature(ARA, b.id, { name: '대외' }),
).resolves.toMatchObject({ name: '대외', body: null, bodyHtml: null });
});
it('없는 서명 · 남의 서명은 RES_001 — 고치기 · 지우기 · 기본 고르기 모두, 아무것도 바꾸지 않는다', async () => {
const mine = await create('사내');
const doyuns = await service.createSignature(DOYUN, { name: '대외' });
for (const id of [doyuns.id, NOT_MINE, 'not-a-uuid']) {
expect(
await failureOf(() =>
service.updateSignature(ARA, id, { name: '바꿈' }),
),
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
expect(await failureOf(() => service.deleteSignature(ARA, id))).toEqual(
{ code: 'RES_001', reason: 'signature-not-found' },
);
expect(
await failureOf(() =>
service.setDefaults(ARA, { newMessageId: mine.id, replyId: id }),
),
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
}
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: mine.id,
replyId: mine.id,
});
expect(repository.signatures).toHaveLength(2);
});
it('기본 고르기 — 두 곳을 따로 · 같은 서명 · 넣지 않음(null)으로, 한 사람에 하나씩', async () => {
const a = await create('사내');
const b = await create('대외');
await expect(
service.setDefaults(ARA, { newMessageId: b.id, replyId: a.id }),
).resolves.toEqual({ newMessageId: b.id, replyId: a.id });
await expect(
service.setDefaults(ARA, {
newMessageId: null,
replyId: b.id.toUpperCase(),
}),
).resolves.toEqual({ newMessageId: null, replyId: b.id });
expect(
repository.signatures.filter((row) => row.isReplyDefault),
).toHaveLength(1);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
});
it('지우면 그 서명을 기본으로 쓰던 곳은 넣지 않음 — 다른 서명이 저절로 기본이 되지 않는다', async () => {
const a = await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await service.deleteSignature(ARA, a.id);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
expect(
(await service.listSignatures(ARA)).items.map((item) => item.id),
).toEqual([b.id]);
});
it('그림 정리는 모든 서명 HTML 의 합집합 — 다른 서명이 쓰는 그림은 남기고, 지운 서명에만 쓰던 그림은 표 · 파일에서 지운다', async () => {
const shared = await storedImage();
const onlyA = await storedImage();
// 막 올린 그림 — 사내를 저장할 때는 아직 어느 서명에도 없지만 남는다(10분 안)
const onlyB = await storedImage({ createdAt: new Date() });
const img = (id: string) => `<img src="${src(id)}" width="96">`;
const a = await create('사내', {
bodyHtml: `${img(shared.id)}${img(onlyA.id)}`,
});
const b = await create('대외', {
bodyHtml: `${img(shared.id)}${img(onlyB.id)}`,
});
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyA.id,
onlyB.id,
]);
// a 에서 shared 를 빼도 b 가 쓰므로 남는다
await service.updateSignature(ARA, a.id, {
name: '사내',
bodyHtml: img(onlyA.id),
});
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyA.id,
onlyB.id,
]);
// a 를 지우면 a 에만 있던 그림이 사라진다
await service.deleteSignature(ARA, a.id);
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyB.id,
]);
expect(existsSync(join(dir, onlyA.fileName))).toBe(false);
expect(existsSync(join(dir, shared.fileName))).toBe(true);
// 남은 서명이 그림을 모두 빼면 둘 다 지운다
await service.updateSignature(ARA, b.id, { name: '대외', body: '글만' });
expect(repository.images).toEqual([]);
});
it('막 올린 그림(10분 안)은 어느 서명에도 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
const fresh = await storedImage({ createdAt: new Date() });
await create('사내');
const { keepUploadedAfter } = repository.changeCalls[0];
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(repository.images.map((row) => row.id)).toEqual([fresh.id]);
});
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
const kept = await storedImage();
const gone = await storedImage();
repository.deletedBeforeNextSave = gone.id;
repository.deletedBeforeNextChange = gone.id;
const saved = await service.saveSignature(ARA, {
const saved = await create('사내', {
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, {
repository.deletedBeforeNextChange = kept.id;
const empty = await service.updateSignature(ARA, saved.id, {
name: '사내',
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
@@ -286,12 +507,7 @@ describe('MailSignatureService', () => {
.mockRejectedValue(new Error('EACCES: permission denied'));
await expect(
service.saveSignature(ARA, {
bodyHtml: '<p>그림 없음</p>',
body: null,
isForNew: true,
isForReply: true,
}),
create('사내', { bodyHtml: '<p>그림 없음</p>' }),
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
expect(repository.images).not.toContainEqual(dropped);
expect(warnSpy).toHaveBeenCalledWith(
@@ -300,12 +516,12 @@ describe('MailSignatureService', () => {
});
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
const row = await create('사내');
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
const saved = await service.saveSignature(ARA, {
const saved = await service.updateSignature(ARA, row.id, {
name: '사내',
bodyHtml,
body: '',
isForNew: false,
isForReply: true,
});
expect(saved.bodyHtml).toBeNull();
expect(saved.body).toBeNull();
@@ -322,27 +538,22 @@ describe('MailSignatureService', () => {
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('')),
create('사내', {
bodyHtml: images.map((image) => img(image.id)).join(''),
}),
),
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
expect(repository.saveCalls).toHaveLength(0);
expect(repository.changeCalls).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'),
);
const saved = await create('사내', {
bodyHtml:
[a, b, c, a].map((image) => img(image.id)).join('') + img(NOT_MINE),
});
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
});
@@ -363,24 +574,207 @@ describe('MailSignatureService', () => {
for (const [input, reason] of cases) {
expect(
await failureOf(() =>
service.saveSignature(ARA, {
isForNew: true,
isForReply: true,
...input,
}),
service.createSignature(ARA, { name: '사내', ...input }),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(repository.saveCalls).toHaveLength(0);
expect(repository.changeCalls).toHaveLength(0);
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
const saved = await service.saveSignature(ARA, {
const saved = await service.createSignature(ARA, {
name: '사내',
body: '가'.repeat(2_000),
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
expect(saved.bodyHtml).toHaveLength(10_000);
});
});
describe('옛 경로 — 「새 메일」 기본 서명 (앱 첫 판 · 옛 웹 탭)', () => {
it('「새 메일」 기본 서명을 네 칸으로(isForReply 는 답장 기본이 같은 서명일 때만) — 없으면 답장 기본 서명, 둘 다 없으면 본문 없음 · 두 표시 false', async () => {
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
});
const image = await storedImage();
const a = await create('사내', {
bodyHtml: `<p>사내</p><img src="${src(image.id)}" width="96">`,
});
const b = await create('대외');
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: a.bodyHtml,
body: '사내 서명',
isForNew: true,
isForReply: true,
images: a.images,
updatedAt: a.updatedAt,
});
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await expect(service.getLegacySignature(ARA)).resolves.toMatchObject({
body: '사내 서명',
isForNew: true,
isForReply: false,
});
// 「새 메일」 기본이 없으면 답장 기본 서명을 isForNew false 로 — 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원이
// 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4)
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: '대외 서명',
isForNew: false,
isForReply: true,
images: [],
updatedAt: b.updatedAt,
});
// 둘 다 없으면 다시 본문 없음
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
});
});
it('저장 — 서명이 없으면 "기본 서명"을 만들고, 두 표시를 기본 둘로 옮긴다', async () => {
const saved = await service.saveLegacySignature(ARA, {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
});
expect(saved).toMatchObject({
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
});
const [row] = (await service.listSignatures(ARA)).items;
expect(row.name).toBe(LEGACY_SIGNATURE_NAME);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: row.id,
replyId: null,
});
});
it('저장 — 「새 메일」 기본 서명의 본문만 바꾸고(이름 그대로), 답장 기본이 다른 서명이면 isForReply false 로는 그대로 둔다', async () => {
const a = await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: true,
isForReply: false,
});
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
['사내', '바꾼 글'],
['대외', '대외 서명'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: b.id,
});
// isForReply true → 답장 기본도 이 서명으로
await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toHaveLength(10_000);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: a.id,
});
});
it('저장 — 「새 메일」 기본이 없으면 옛 GET 이 보인 답장 기본 서명을 고친다 (새로 만들지 않는다)', async () => {
await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
const saved = await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: false,
isForReply: true,
});
expect(saved).toMatchObject({
body: '바꾼 글',
isForNew: false,
isForReply: true,
});
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
['사내', '사내 서명'],
['대외', '바꾼 글'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
});
it('저장 — 「새 메일」을 끈 채로 거듭 저장해도 "기본 서명"이 늘지 않는다 (답장 기본으로 · 두 표시를 끄면 이름으로 다시 찾는다)', async () => {
for (let index = 0; index < 3; index += 1) {
await service.saveLegacySignature(ARA, {
body: `글 ${index}`,
isForNew: false,
isForReply: true,
});
}
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
[LEGACY_SIGNATURE_NAME, '글 2'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: items[0].id,
});
// 두 표시를 모두 끄면 기본 서명이 없어진다 — 그 뒤 저장은 이름으로 같은 서명을 다시 찾는다
for (let index = 3; index < 5; index += 1) {
await service.saveLegacySignature(ARA, {
body: `글 ${index}`,
isForNew: false,
isForReply: false,
});
}
const after = (await service.listSignatures(ARA)).items;
expect(after.map((item) => [item.name, item.body])).toEqual([
[LEGACY_SIGNATURE_NAME, '글 4'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: null,
});
});
it(`저장 — 서명을 새로 만들어야 하는데 이미 ${SIGNATURES_MAX}개면 400 VAL_001`, async () => {
for (let index = 1; index <= SIGNATURES_MAX; index += 1) {
await create(`서명 ${index}`);
}
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
expect(
await failureOf(() =>
service.saveLegacySignature(ARA, {
body: '글',
isForNew: true,
isForReply: true,
}),
),
).toEqual({ code: 'VAL_001', reason: 'too-many-signatures' });
});
});
@@ -467,7 +861,8 @@ describe('MailSignatureService', () => {
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
});
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(서명 여럿이 함께 쓰는 칸) — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
expect(SIGNATURE_IMAGES_STORED_MAX).toBe(20);
const results = await Promise.allSettled(
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
upload(pngBytes(10, 10)),
@@ -525,7 +920,7 @@ describe('MailSignatureService', () => {
});
for (const [user, id] of [
[DOYUN, mine.id],
[ARA, '11111111-1111-4111-8111-111111111111'],
[ARA, NOT_MINE],
]) {
expect(await failureOf(() => service.openImage(user, id))).toEqual({
code: 'RES_001',
@@ -1,10 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
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 { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
import {
findSignatureImageIds,
rewriteSignatureImages,
@@ -14,6 +16,8 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
LEGACY_SIGNATURE_NAME,
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
@@ -22,9 +26,16 @@ import {
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import {
MailSignatureRepository,
type SignaturePlan,
type SignatureSnapshot,
} from './mail-signature.repository';
import type {
LegacyMailSignature,
LegacyMailSignatureInput,
MailSignature,
MailSignatureDefaults,
MailSignatureInput,
SignatureImage,
SignatureImageUpload,
@@ -39,18 +50,36 @@ import {
} from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
/** 옛 경로 — 「새 메일」 · 「답장 · 전달」 기본 서명이 모두 없을 때 (앱 첫 판은 본문이 없으면 서명을 넣지 않는다) */
const NO_LEGACY_SIGNATURE: LegacyMailSignature = {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
};
/** 정화 · 길이를 지난 서명 본문 — 그림은 잠근 뒤 다시 맞춘다 */
interface PreparedSignatureBody {
readonly bodyHtml: string | null;
readonly body: string | null;
}
function normalizeUser(email: string): string {
return email.trim().toLowerCase();
}
/** 서명 id · 그림 id — 서버가 만든 UUID(소문자). 경로 파이프가 소문자로 바꾸지만 본문에서 온 id(기본 고르기)도 같게 */
function normalizeId(id: string): string {
return id.trim().toLowerCase();
}
/** 이름 견주기 — ASCII 대소문자만 무시한다 (화면과 같다 — 한글 · 그 밖의 글자는 그대로 견준다) */
function nameKey(name: string): string {
return name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}
function toImage(row: MailSignatureImageEntity): SignatureImage {
return {
id: row.id,
@@ -61,6 +90,67 @@ function toImage(row: MailSignatureImageEntity): SignatureImage {
};
}
/** 서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (표에 없는 그림은 뺀다) */
function imagesOf(
html: string | null,
images: readonly MailSignatureImageEntity[],
): SignatureImage[] {
if (!html) return [];
const byId = new Map(images.map((image) => [image.id, image]));
return findSignatureImageIds(html).flatMap((id) => {
const image = byId.get(id);
return image ? [toImage(image)] : [];
});
}
function toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
): MailSignature {
return {
id: row.id,
name: row.name,
bodyHtml: row.bodyHtml,
body: row.bodyText,
images: imagesOf(row.bodyHtml, images),
updatedAt: row.updatedAt.toISOString(),
};
}
function defaultsOf(
signatures: readonly MailSignatureEntity[],
): MailSignatureDefaults {
return {
newMessageId: signatures.find((row) => row.isNewMessageDefault)?.id ?? null,
replyId: signatures.find((row) => row.isReplyDefault)?.id ?? null,
};
}
/**
* 새 서명의 만든 시각 — 그 사람의 마지막 서명보다 늦게(같은 밀리초면 1ms 뒤로). 목록은 만든 차례라
* 잠근 차례대로 늘 뒤에 붙는다 (같은 시각이면 id 차례가 되어 들쭉날쭉하지 않게)
*/
function createdAfter(
signatures: readonly MailSignatureEntity[],
now: Date,
): Date {
const last = Math.max(0, ...signatures.map((row) => row.createdAt.getTime()));
return new Date(Math.max(now.getTime(), last + 1));
}
/** 모든 서명 HTML 이 가리키는 그림 — 합집합 (지울 그림을 정하는 기준) */
function referencedImageIdsOf(
signatures: readonly Pick<MailSignatureEntity, 'bodyHtml'>[],
): string[] {
return [
...new Set(
signatures.flatMap((row) =>
row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [],
),
),
];
}
/** 올린 이름의 확장자 (소문자, 점 포함) */
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
@@ -68,15 +158,19 @@ function extensionOf(name: string): string {
}
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지), 서버 DB 에 둔다.
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
* - 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시다 — 서명이 하나도 없던 사람의 첫 서명이 두 곳의 기본이 된다
* - 서명 그림은 모든 서명이 함께 쓰는 칸(한 사람 20장)이다 — 저장 · 지우기 때 **어느 서명도 가리키지 않는** 그림을 지운다(막 올린 것은 남긴다)
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
* - 서명 · 그림은 주인만 본다 — 남의 id 는 없는 것과 같다 (RES_001)
* - 옛 경로(`GET · PUT /api/mail-signature` — 앱 첫 판 · 16단계 전의 웹 탭)는 「새 메일」 기본 서명을 15단계까지의 모양으로 다룬다
* 서명 · 이름 · 글은 로그에 남기지 않는다 (사유 · 수 · 가린 주소만)
*/
@Injectable()
export class MailSignatureService {
private readonly logger = new Logger(MailSignatureService.name);
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
/** 사용자별 그림 올리기 차례 — 보관 한도(20장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
private readonly uploadQueues = new Map<string, Promise<void>>();
constructor(
@@ -84,76 +178,236 @@ export class MailSignatureService {
private readonly storage: SignatureImageStorage,
) {}
async getSignature(userEmail: string): Promise<MailSignature> {
/** 내 서명 — 만든 차례, 한 번에 모두 (hasNext 늘 false) */
async listSignatures(
userEmail: string,
): Promise<PaginatedResult<MailSignature>> {
const user = normalizeUser(userEmail);
const [row, images] = await Promise.all([
this.repository.findSignature(user),
const [rows, images] = await Promise.all([
this.repository.listSignatures(user),
this.repository.listImages(user),
]);
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
return this.toSignature(row, images);
return toPaginatedResult(
rows.map((row) => toSignature(row, images)),
{ hasNext: false, nextCursor: null, total: rows.length },
);
}
/**
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
* 서명을 만든다 — 서명이 하나도 없던 사람이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다.
* 11번째 · 같은 이름(ASCII 대소문자 무시)이면 VAL_001 (아무것도 저장하지 않는다)
*/
async saveSignature(
async createSignature(
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,
const prepared = await this.prepareBody(user, input);
const id = randomUUID();
const result = await this.change(user, (snapshot, now) => {
if (snapshot.signatures.length >= SIGNATURES_MAX) {
throw new BusinessException('VAL_001', {
context: {
reason: 'too-many-signatures',
count: snapshot.signatures.length,
},
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
};
},
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(saved.removed, user);
});
}
this.assertNameFree(snapshot.signatures, input.name, null);
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
const row = {
id,
userEmail: user,
name: input.name,
bodyHtml,
bodyText: prepared.body,
createdAt: createdAfter(snapshot.signatures, now),
updatedAt: now,
};
return {
insert: row,
// 첫 서명은 두 곳의 기본 — 지금까지 서명이 처음에 두 곳에 들어가던 것과 같다
...(snapshot.signatures.length === 0
? { defaults: { newMessageId: id, replyId: id } }
: {}),
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
};
});
return this.signatureIn(result, id);
}
return this.toSignature(saved.signature, saved.images);
/**
* 서명을 고친다(이름 · 본문 — 전체 교체). 기본 표시는 그대로. 없는 서명 · 남의 서명은 RES_001, 다른 서명과 같은 이름은 VAL_001.
* 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올린 그림(10분 안)은 남긴다 (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
*/
async updateSignature(
userEmail: string,
signatureId: string,
input: MailSignatureInput,
): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const id = normalizeId(signatureId);
const prepared = await this.prepareBody(user, input);
const result = await this.change(user, (snapshot, now) => {
this.requireSignature(snapshot, id);
this.assertNameFree(snapshot.signatures, input.name, id);
const update = {
id,
name: input.name,
bodyHtml: this.keepExistingImages(prepared.bodyHtml, snapshot),
bodyText: prepared.body,
updatedAt: now,
};
return {
update,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.map((row) => (row.id === id ? update : row)),
),
};
});
return this.signatureIn(result, id);
}
/**
* 서명을 지운다 — 이 서명을 기본으로 쓰던 곳은 "넣지 않음"이 된다(다른 서명이 저절로 기본이 되지 않는다).
* 이 서명에만 쓰던 그림은 함께 지운다. 없는 서명 · 남의 서명은 RES_001
*/
async deleteSignature(userEmail: string, signatureId: string): Promise<void> {
const user = normalizeUser(userEmail);
const id = normalizeId(signatureId);
await this.change(user, (snapshot) => {
this.requireSignature(snapshot, id);
return {
removeId: id,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.filter((row) => row.id !== id),
),
};
});
}
/** 「메일에 넣을 서명」 둘 — null 은 넣지 않음 */
async getDefaults(userEmail: string): Promise<MailSignatureDefaults> {
return defaultsOf(
await this.repository.listSignatures(normalizeUser(userEmail)),
);
}
/** 「메일에 넣을 서명」 둘을 바꾼다 — 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 RES_001 (아무것도 바꾸지 않는다) */
async setDefaults(
userEmail: string,
defaults: MailSignatureDefaults,
): Promise<MailSignatureDefaults> {
const user = normalizeUser(userEmail);
const wanted: MailSignatureDefaults = {
newMessageId:
defaults.newMessageId === null
? null
: normalizeId(defaults.newMessageId),
replyId: defaults.replyId === null ? null : normalizeId(defaults.replyId),
};
const result = await this.change(user, (snapshot) => {
for (const id of [wanted.newMessageId, wanted.replyId]) {
if (id !== null) this.requireSignature(snapshot, id);
}
return {
defaults: wanted,
referencedImageIds: referencedImageIdsOf(snapshot.signatures),
};
});
return defaultsOf(result.signatures);
}
/**
* 옛 경로 — 「새 메일」 기본 서명을 15단계까지의 모양으로 (앱 첫 판이 읽는다). 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을
* isForNew false · isForReply true 로, 둘 다 없으면 본문 없음 · 두 표시 false.
* 「새 메일」 기본이 있을 때 isForReply 는 답장 기본이 같은 서명일 때만 true — 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계)
*/
async getLegacySignature(userEmail: string): Promise<LegacyMailSignature> {
const user = normalizeUser(userEmail);
const [rows, images] = await Promise.all([
this.repository.listSignatures(user),
this.repository.listImages(user),
]);
return toLegacySignature(rows, images);
}
/**
* 옛 경로의 저장(16단계 전의 웹 탭) — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다.
* 기본 서명이 둘 다 없으면 "기본 서명"이라는 이름의 서명(ASCII 대소문자 무시 — 있으면 그것, 없으면 새로 만든다)을 쓴다 —
* 옛 탭이 두 표시를 끈 채 저장을 거듭해도 서명이 늘지 않게.
* isForNew → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), isForReply → 「답장 · 전달」 기본을 이 서명으로
* (false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로 둔다)
*/
async saveLegacySignature(
userEmail: string,
input: LegacyMailSignatureInput,
): Promise<LegacyMailSignature> {
const user = normalizeUser(userEmail);
const prepared = await this.prepareBody(user, input);
const newId = randomUUID();
const result = await this.change(user, (snapshot, now) => {
const current = defaultsOf(snapshot.signatures);
const target =
legacyTargetOf(snapshot.signatures) ??
snapshot.signatures.find(
(row) => nameKey(row.name) === nameKey(LEGACY_SIGNATURE_NAME),
) ??
null;
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
const id = target?.id ?? newId;
const defaults: MailSignatureDefaults = {
newMessageId: input.isForNew
? id
: current.newMessageId === id
? null
: current.newMessageId,
replyId: input.isForReply
? id
: current.replyId === id
? null
: current.replyId,
};
if (target) {
const update = {
id,
name: target.name,
bodyHtml,
bodyText: prepared.body,
updatedAt: now,
};
return {
update,
defaults,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.map((row) => (row.id === id ? update : row)),
),
};
}
if (snapshot.signatures.length >= SIGNATURES_MAX) {
throw new BusinessException('VAL_001', {
context: {
reason: 'too-many-signatures',
count: snapshot.signatures.length,
},
});
}
const row = {
id,
userEmail: user,
name: LEGACY_SIGNATURE_NAME,
bodyHtml,
bodyText: prepared.body,
createdAt: createdAfter(snapshot.signatures, now),
updatedAt: now,
};
return {
insert: row,
defaults,
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
};
});
return toLegacySignature(result.signatures, result.images);
}
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
@@ -284,59 +538,125 @@ export class MailSignatureService {
return loaded;
}
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
private async sanitizeSignatureHtml(
/**
* 서명 본문 — 텍스트 2,000자, 서식은 정화 전 비용 · 정화 뒤 10,000자(JS 문자열 길이), 서로 다른 그림 3장(정화가 남의 그림은 지웠다 —
* 같은 그림 두 번은 한 장). 넘으면 VAL_001
*/
private async prepareBody(
user: string,
html: string | null | undefined,
): Promise<string | null> {
if (!html) return null;
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
if (html.length > MAX_BODY_HTML_LENGTH) {
input: { readonly bodyHtml?: string | null; readonly body?: string | null },
): Promise<PreparedSignatureBody> {
const body = input.body ? input.body : null;
if (body !== null && body.length > SIGNATURE_TEXT_MAX_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,
},
context: { reason: 'signature-text-too-long', length: body.length },
});
}
const sanitized = sanitizeOutgoingHtml(html, {
signatureImageIds: await this.imageIdsOf(user),
const signatureImageIds = input.bodyHtml
? await this.imageIdsOf(user)
: undefined;
const sanitized = sanitizeStoredHtml(input.bodyHtml, {
maxLength: SIGNATURE_HTML_MAX_LENGTH,
signatureImageIds,
});
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
if (!sanitized.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-long',
reason:
sanitized.problem === 'too-complex'
? 'signature-html-too-complex'
: 'signature-html-too-long',
length: sanitized.length,
},
});
}
return sanitized === '' ? null : sanitized;
const referenced = sanitized.html
? findSignatureImageIds(sanitized.html)
: [];
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-too-many-images',
count: referenced.length,
},
});
}
return { bodyHtml: sanitized.html, body };
}
private toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
/**
* 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장 · 정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게).
* 남는 것이 없으면 null
*/
private keepExistingImages(
html: string | null,
snapshot: SignatureSnapshot,
): string | null {
if (html === null) return null;
const existing = new Set(snapshot.images.map((image) => image.id));
const kept = rewriteSignatureImages(html, (id) =>
existing.has(id) ? signatureImagePath(id) : null,
);
return kept.trim() !== '' ? kept : null;
}
/** 같은 이름(ASCII 대소문자 무시)의 다른 서명이 있으면 VAL_001 */
private assertNameFree(
signatures: readonly MailSignatureEntity[],
name: string,
exceptId: string | null,
): void {
const key = nameKey(name);
if (
signatures.some((row) => row.id !== exceptId && nameKey(row.name) === key)
) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-name-taken' },
});
}
}
/** 그 사람의 서명이어야 한다 — 없으면(남의 것 · 형식이 틀린 id 포함) RES_001 */
private requireSignature(snapshot: SignatureSnapshot, id: string): void {
if (!snapshot.signatures.some((row) => row.id === id)) {
throw new BusinessException('RES_001', {
context: { reason: 'signature-not-found' },
});
}
}
/** 잠근 뒤 바꾸고, 지운 그림의 파일을 지운다 */
private async change(
user: string,
plan: (snapshot: SignatureSnapshot, now: Date) => SignaturePlan,
) {
const now = new Date();
const result = await this.repository.change(
user,
(snapshot) => plan(snapshot, now),
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(result.removed, user);
return result;
}
/** 바꾼 뒤의 서명 하나 — 방금 넣거나 고친 줄은 늘 있다 */
private signatureIn(
result: {
readonly signatures: readonly MailSignatureEntity[];
readonly images: readonly MailSignatureImageEntity[];
},
id: string,
): 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(),
};
const row = result.signatures.find((candidate) => candidate.id === id);
if (!row) {
throw new BusinessException('SYS_001', {
context: { reason: 'signature-missing-after-save' },
});
}
return toSignature(row, result.images);
}
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
@@ -370,3 +690,38 @@ export class MailSignatureService {
return run;
}
}
/**
* 옛 경로가 다루는 서명 — 「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 null.
* 답장 기본까지 보는 까닭: 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원(옮기면 답장 기본만 있다)이 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4).
* 옛 PUT 도 이 서명을 고친다 — 옛 탭이 읽은 서명을 그대로 저장하게
*/
function legacyTargetOf(
rows: readonly MailSignatureEntity[],
): MailSignatureEntity | null {
return (
rows.find((candidate) => candidate.isNewMessageDefault) ??
rows.find((candidate) => candidate.isReplyDefault) ??
null
);
}
/**
* 옛 경로의 모양 — 「새 메일」 기본 서명(isForNew true · isForReply 는 답장 기본도 그 서명일 때), 없으면 「답장 · 전달」 기본 서명
* (isForNew false · isForReply true), 둘 다 없으면 본문 없음 · 두 표시 false
*/
function toLegacySignature(
rows: readonly MailSignatureEntity[],
images: readonly MailSignatureImageEntity[],
): LegacyMailSignature {
const row = legacyTargetOf(rows);
if (!row) return NO_LEGACY_SIGNATURE;
return {
bodyHtml: row.bodyHtml,
body: row.bodyText,
isForNew: row.isNewMessageDefault,
isForReply: row.isReplyDefault,
images: imagesOf(row.bodyHtml, images),
updatedAt: row.updatedAt.toISOString(),
};
}
@@ -1,7 +1,7 @@
import type { SignatureImageType } from './signature-image-format';
/**
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다 (16단계부터 한 사람에 여러 개).
*/
export interface SignatureImage {
@@ -14,21 +14,51 @@ export interface SignatureImage {
readonly height: number;
}
/** 서명 하나 — `GET · POST /api/mail-signatures` · `PUT /api/mail-signatures/{id}` (16단계) */
export interface MailSignature {
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
readonly id: string;
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시) */
readonly name: string;
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하. 없으면 null */
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하. 없으면 null */
readonly body: string | null;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
readonly updatedAt: string;
}
/** 서명 만들기 · 고치기 본문 (전체 교체) */
export interface MailSignatureInput {
/** 앞뒤 공백을 뺀 값 (요청 검증이 지운다) */
readonly name: string;
readonly bodyHtml?: string | null;
readonly body?: string | null;
}
/** 「메일에 넣을 서명」 둘 — `GET · PUT /api/mail-signature-defaults` (16단계). null = 넣지 않음 */
export interface MailSignatureDefaults {
readonly newMessageId: string | null;
readonly replyId: string | null;
}
/**
* 옛 경로의 서명 — `GET /api/mail-signature` (앱 첫 판 · 16단계 전의 웹 탭). 「새 메일」 기본 서명을 15단계까지의 네 칸으로 내준다
* (없으면 「답장 · 전달」 기본 서명을 isForNew false 로). isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 이 서명
*/
export interface LegacyMailSignature {
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
readonly body: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 저장한 적 없으면 null */
/** 기본 서명이 둘 다 없으면 null */
readonly updatedAt: string | null;
}
/** PUT /api/mail-signature 본문 */
export interface MailSignatureInput {
/** 옛 경로의 저장 본문 — `PUT /api/mail-signature` */
export interface LegacyMailSignatureInput {
readonly bodyHtml?: string | null;
readonly body?: string | null;
readonly isForNew: boolean;
@@ -0,0 +1,184 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailSignatureDefaultsDto,
MailSignatureDto,
MailSignaturePageDto,
SaveMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureService } from './mail-signature.service';
import { MailSignatureIdPipe } from './signature-image-id.pipe';
/** 서명 저장(만들기 · 고치기)의 400 VAL_001 사유 */
const SAVE_REJECTIONS =
`형식 오류 · 이름(앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자 · 제어 문자 없음) · 같은 이름의 다른 서명(영문 대소문자 무시) · ` +
`텍스트 서명 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · 정화한 서식 서명 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · ` +
`서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 모르는 필드`;
const SIGNATURE_ID_PARAM = {
name: 'id',
description: '서명 id (서버가 만든 UUID) — 형식이 틀리면 404 RES_001',
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
};
// 여러 서명 (계약 2절 "서명" — 16단계) — 로그인한 직원 자신의 서명만 다룬다. 서명 그림 경로는 그대로 /api/mail-signature/images (mail-signature.controller.ts)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signatures')
export class MailSignaturesController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '내 서명 목록',
description: `만든 차례 · ${SIGNATURES_MAX}개까지 한 번에 모두 (hasNext=false). 「메일에 넣을 서명」은 /api/mail-signature-defaults`,
})
@ApiStandardResponse(MailSignaturePageDto, { description: '서명 목록' })
list(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.listSignatures(user.email);
}
@Post()
@ApiOperation({
summary: '서명 만들기',
description:
`${SIGNATURES_MAX}개까지. 서명이 하나도 없던 직원이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(그 밖에는 기본을 바꾸지 않는다). ` +
'bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, {
status: HttpStatus.CREATED,
description: '만든 서명',
})
@ApiErrorResponse(
'VAL_001',
`${SAVE_REJECTIONS} · 이미 ${SIGNATURES_MAX}개 — 저장하지 않았다`,
)
create(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: SaveMailSignatureDto,
) {
return this.signatures.createSignature(user.email, dto);
}
@Put(':id')
@ApiOperation({
summary: '서명 고치기 (이름 · 본문 — 전체 교체)',
description:
'「메일에 넣을 서명」 표시는 그대로다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiParam(SIGNATURE_ID_PARAM)
@ApiStandardResponse(MailSignatureDto, { description: '고친 서명' })
@ApiErrorResponse('VAL_001', `${SAVE_REJECTIONS} — 저장하지 않았다`)
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailSignatureIdPipe) id: string,
@Body() dto: SaveMailSignatureDto,
) {
return this.signatures.updateSignature(user.email, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({
summary: '서명 지우기',
description:
'이 서명을 기본으로 쓰던 곳(새 메일 · 답장과 전달)은 넣지 않음이 된다 — 다른 서명이 저절로 기본이 되지 않는다. ' +
'이 서명에만 쓰던 그림은 함께 지운다. 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 그대로다.',
})
@ApiParam(SIGNATURE_ID_PARAM)
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: '지웠다 — 본문 없음',
})
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
async remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailSignatureIdPipe) id: string,
): Promise<void> {
await this.signatures.deleteSignature(user.email, id);
}
}
// 「메일에 넣을 서명」 둘 (계약 2절 "서명" — 16단계) — 새 메일 · 답장과 전달에 저절로 넣는 서명. 15단계까지의 두 체크(isForNew · isForReply)를 대신한다
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signature-defaults')
export class MailSignatureDefaultsController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '메일에 넣을 서명',
description:
'새 메일 · 답장과 전달에 넣는 서명의 id — null 이면 넣지 않음. 서명이 하나도 없으면 둘 다 null',
})
@ApiStandardResponse(MailSignatureDefaultsDto, {
description: '메일에 넣을 서명',
})
get(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getDefaults(user.email);
}
@Put()
@ApiOperation({
summary: '메일에 넣을 서명 바꾸기 (전체 교체)',
description:
'두 키를 모두 보낸다(null = 넣지 않음). 두 곳이 같은 서명이어도 된다. 바꾼 뒤의 값을 돌려준다',
})
@ApiStandardResponse(MailSignatureDefaultsDto, {
description: '바꾼 뒤의 값',
})
@ApiErrorResponse('VAL_001', '형식 오류 · 빠진 키 · 모르는 필드')
@ApiErrorResponse(
'RES_001',
'내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림) — 아무것도 바꾸지 않았다',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailSignatureDefaultsDto,
) {
return this.signatures.setDefaults(user.email, dto);
}
}
@@ -2,10 +2,18 @@ import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
/** 서명 그림 id — 서버가 만든 UUID */
const SIGNATURE_IMAGE_ID =
/** 서명 · 서명 그림 id — 서버가 만든 UUID */
const SERVER_UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** 경로의 UUID 를 소문자로 — 형식이 틀리면 "그런 것이 없다"와 같으므로 RES_001 (그 사유로) */
function serverUuid(value: unknown, reason: string): string {
if (typeof value === 'string' && SERVER_UUID.test(value)) {
return value.toLowerCase();
}
throw new BusinessException('RES_001', { context: { reason } });
}
/**
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
@@ -13,11 +21,14 @@ const SIGNATURE_IMAGE_ID =
@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' },
});
return serverUuid(value, 'malformed-signature-image-id');
}
}
/** 경로의 서명 id 형식 확인 (16단계 — `/api/mail-signatures/{id}`) — 그림 id 와 같은 규칙, 틀리면 RES_001 */
@Injectable()
export class MailSignatureIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
return serverUuid(value, 'malformed-signature-id');
}
}
@@ -1,5 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { validate, type ValidationError } from 'class-validator';
import {
MAX_VACATION_SUBJECT_LENGTH,
@@ -18,6 +18,22 @@ async function constraintsOf(body: object): Promise<string[]> {
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
/** 안쪽 객체(external — 16단계)까지 — `칸.규칙` 모양으로 모두 */
async function allConstraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(MailVacationDto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
const flatten = (list: ValidationError[], prefix: string): string[] =>
list.flatMap((error) => [
...Object.keys(error.constraints ?? {}).map(
(name) => `${prefix}${error.property}.${name}`,
),
...flatten(error.children ?? [], `${prefix}${error.property}.`),
]);
return flatten(errors, '');
}
describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
const enabled = {
isEnabled: true,
@@ -72,7 +88,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual([]);
});
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 16단계부터 서식 본문(htmlBody)도 받는다`, async () => {
for (const isEnabled of [false, true]) {
expect(
await constraintsOf({
@@ -102,7 +118,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
);
expect(
await constraintsOf({ ...enabled, htmlBody: '<p>휴가</p>' }),
).toEqual(['whitelistValidation']);
).toEqual([]);
});
it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => {
@@ -212,6 +228,134 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
});
});
describe('부재중 서식 본문 · 회사 밖 응답 (16단계)', () => {
const enabled = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
const external = {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
};
it('htmlBody · external 은 보내지 않아도 된다(옛 요청) — 보내면 htmlBody 는 글 · null, external 은 네 키(글 칸은 null 가능) · null', async () => {
expect(await allConstraintsOf(enabled)).toEqual([]);
expect(
await allConstraintsOf({
...enabled,
htmlBody: '<p>휴가</p>',
external,
}),
).toEqual([]);
expect(
await allConstraintsOf({ ...enabled, htmlBody: null, external: null }),
).toEqual([]);
for (const mode of ['same', 'none', 'separate']) {
expect(
await allConstraintsOf({
...enabled,
external: { mode, subject: null, textBody: null, htmlBody: null },
}),
).toEqual([]);
}
});
it('형식이 틀리면 VAL_001 — 모르는 고르기 · 빠진 키 · 글이 아닌 값 · 모르는 키 · 객체가 아님', async () => {
expect(await allConstraintsOf({ ...enabled, htmlBody: 1 })).toEqual([
'htmlBody.isString',
]);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, mode: 'internal' },
}),
).toEqual(['external.mode.isIn']);
// 빠진 키 — 전체 교체라 null 로 보낸다(이 검사는 stopAtFirstError 를 빼 걸린 규칙이 모두 보인다)
expect(
await allConstraintsOf({ ...enabled, external: { mode: 'same' } }),
).toEqual(
expect.arrayContaining([
'external.subject.isString',
'external.textBody.isString',
'external.htmlBody.isString',
]),
);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, subject: 1, htmlBody: [] },
}),
).toEqual(
expect.arrayContaining([
'external.subject.isString',
'external.htmlBody.isString',
]),
);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, isActive: true },
}),
).toEqual(['external.isActive.whitelistValidation']);
for (const value of [[], 'same', 1]) {
expect(await allConstraintsOf({ ...enabled, external: value })).toEqual(
expect.arrayContaining(['external.isObject']),
);
}
});
it(`회사 밖 제목 · 내용은 사내 칸과 같은 형식 한도를 늘 본다 — ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 · 제어 문자 (켤 때의 150 · 600자는 서비스가)`, async () => {
for (const isEnabled of [false, true]) {
expect(
await allConstraintsOf({
...enabled,
isEnabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF),
},
}),
).toEqual([]);
}
expect(
await allConstraintsOf({
...enabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
},
}),
).toEqual([
'external.subject.maxCodeUnits',
'external.textBody.maxCodeUnits',
]);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, subject: 'a\nb', textBody: 'a\u0000' },
}),
).toEqual(['external.subject.matches', 'external.textBody.matches']);
// 켤 때의 한도(150 · 600)를 넘는 글도 형식으로는 맞다 — 켤 때 따로 쓰기면 서비스가 VAL_001
expect(
await allConstraintsOf({
...enabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
},
}),
).toEqual([]);
});
});
describe('isUtcDateTime', () => {
it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => {
expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true);
+122 -10
View File
@@ -1,15 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
Matches,
ValidateBy,
ValidateIf,
ValidateNested,
type ValidationOptions,
} from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAIL_VACATION_EXTERNAL_MODES,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
@@ -18,7 +25,8 @@ import {
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
import type {
MailVacation,
MailVacationExternal,
MailVacationExternalMode,
MailVacationState,
} from '@/modules/mail/mail.types';
@@ -46,16 +54,84 @@ export function IsUtcDateTime(options: ValidationOptions) {
);
}
/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */
/** null 은 "날짜 없음" · "쓴 적 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 키를 모두 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
const HTML_BODY_DESCRIPTION =
`서식 본문 — 서식 본문과 같은 허용 목록으로 정화해 저장한다(그림은 넣을 수 없다). 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지(켤 때만 본다 — 넘으면 400 VAL_001). ` +
'null · 빈 글이면 글만. 받는 사람에게는 글(textBody) + 서식 두 벌로 간다';
/**
* 회사 밖 응답 (16단계) — 네 키를 모두 보낸다(글 칸은 null 가능). 따로 쓰기(separate)이고 켤 때는 제목 · 내용이 1자 이상 ·
* 제목 150자 · 내용 600자 · 서식 10,000자(서비스가 본다). 다른 고르기여도 써 둔 글은 그대로 저장한다
*/
export class MailVacationExternalDto implements MailVacationExternal {
@ApiProperty({
description:
'회사 밖(봉투 보낸 사람의 도메인이 회사 메일 도메인이 아님)에서 온 메일에 — same: 사내와 같은 응답(처음 값) · none: 보내지 않기 · separate: 아래 제목 · 내용으로',
enum: MAIL_VACATION_EXTERNAL_MODES,
example: 'separate',
})
@IsIn(MAIL_VACATION_EXTERNAL_MODES, {
message: '회사 밖 응답을 다시 선택해 주세요.',
})
mode!: MailVacationExternalMode;
@ApiProperty({
description: `회사 밖에 보낼 제목 — 켜고 따로 쓰기면 1~${MAX_VACATION_SUBJECT_LENGTH}자, 그 밖에는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지. 빈 글은 null 로 저장한다. 제어 문자는 쓸 수 없다`,
nullable: true,
type: String,
maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
example: 'Out of office',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 제목 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, {
message: `회사 밖에 보낼 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '회사 밖에 보낼 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
})
subject!: string | null;
@ApiProperty({
description: `회사 밖에 보낼 내용(보이는 글) — 켜고 따로 쓰기면 1~${MAX_VACATION_TEXT_LENGTH}자, 그 밖에는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지. 빈 글은 null 로 저장한다`,
nullable: true,
type: String,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: 'I am out of the office until November 7.',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, {
message: `회사 밖에 보낼 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
@Matches(MULTI_LINE_TEXT_PATTERN, {
message:
'회사 밖에 보낼 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
})
textBody!: string | null;
@ApiProperty({
description: `회사 밖에 보낼 ${HTML_BODY_DESCRIPTION}`,
nullable: true,
type: String,
example:
'<p style="margin: 0">I am out of the office until <strong>November 7</strong>.</p>',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
htmlBody!: string | null;
}
/**
* 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자 · 서식 10,000자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
* 켤 때만의 한도를 함께 걸 수 없다.
* 16단계의 htmlBody · external 은 보내지 않아도 된다 — 없으면 글만 · 사내와 같은 응답(회사 밖 글 칸 null)으로 저장한다(옛 화면이 보는 그대로)
*/
export class MailVacationDto implements MailVacation {
export class MailVacationDto {
@ApiProperty({
description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`,
example: true,
@@ -102,8 +178,8 @@ export class MailVacationDto implements MailVacation {
@ApiProperty({
description:
`자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`,
`자동 응답 내용(보이는 글 — 서식을 뺀 글). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서식 본문(htmlBody)이 있으면 받는 쪽의 글 판이다. 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
})
@@ -115,16 +191,52 @@ export class MailVacationDto implements MailVacation {
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
})
textBody!: string;
@ApiPropertyOptional({
description: `${HTML_BODY_DESCRIPTION} (16단계 — 보내지 않으면 null)`,
nullable: true,
type: String,
example:
'<p style="margin: 0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
})
@IsOptional()
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
htmlBody?: string | null;
@ApiPropertyOptional({
description:
'회사 밖에서 온 메일에 하는 응답 (16단계) — 보내지 않거나 null 이면 사내와 같은 응답(글 칸 null)으로 저장한다',
type: MailVacationExternalDto,
})
@IsOptional()
@IsObject({ message: '회사 밖 응답 형식이 올바르지 않습니다.' })
@ValidateNested()
@Type(() => MailVacationExternalDto)
external?: MailVacationExternalDto | null;
}
/**
* GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지.
* PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다
* 16단계의 htmlBody · external 은 응답에 늘 있다. PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001).
* 응답 문서용이라 검사 데코레이터를 더하지 않는다
*/
export class MailVacationStateDto
extends MailVacationDto
extends OmitType(MailVacationDto, ['htmlBody', 'external'] as const)
implements MailVacationState
{
@ApiProperty({
description: `${HTML_BODY_DESCRIPTION} — 늘 있다(없으면 null)`,
nullable: true,
type: String,
})
htmlBody!: string | null;
@ApiProperty({
description: '회사 밖에서 온 메일에 하는 응답 — 늘 있다',
type: MailVacationExternalDto,
})
external!: MailVacationExternalDto;
@ApiProperty({
description:
'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
@@ -775,4 +775,60 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
}
});
});
/**
* 쓰기 화면의 서명 자리(16단계 — 계약 2절 "앱이 만드는 모양") — 빈 문단 하나 뒤의 **속성 없는 div 하나**가 서명 경계다.
* 웹 편집기도 같은 경계로 서명을 넣고 임시 저장 · 이어 쓰기에서 다시 찾으므로, 보내는 정화 · 임시 저장본 다시 읽기 · 받은 메일 정화를 지나도 남아야 한다
*/
describe('서명 자리 — 빈 문단 뒤 속성 없는 div (16단계)', () => {
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const signatureBlock =
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></div>`;
const composed =
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75">` +
'<p style="margin: 0">안녕하세요.</p><p style="margin: 0"><br></p>' +
signatureBlock +
'<div data-ara-quote-start=""></div>' +
'<blockquote style="margin: 0; padding-left: 12px; border-left: 2px solid #D0D5DD"><p style="margin: 0">원문</p></blockquote></div>';
/** 정화한 서명 자리 — 빈 문단 바로 뒤의 속성 없는 div, 안은 서명 */
const keptBoundary =
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="" /></div>`;
it('보낼 때 · 임시 저장할 때(인용 경계 표시를 남기는 정화)와 저장본을 다시 열 때(표시 없는 정화) 모두 경계 div 가 속성 없이 남는다', () => {
const ids = new Set([IMAGE_ID]);
for (const options of [
{ signatureImageIds: ids, keepQuoteStart: true },
{ signatureImageIds: ids },
]) {
const html = clean(composed, options);
expect(html).toContain(keptBoundary);
// 정화한 것을 다시 정화해도 같다 — 이어 쓰기를 거듭해도 경계가 그대로다
expect(clean(html, { signatureImageIds: ids })).toBe(
clean(composed, { signatureImageIds: ids }),
);
}
});
it('받은 메일 정화(보낸편지함 · 받는 사람이 다시 읽을 때)도 속성 없는 div 를 그대로 둔다', () => {
const sent = clean(composed, { signatureImageIds: new Set([IMAGE_ID]) });
const { html } = sanitizeMailHtml(sent);
expect(html).toContain(
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>',
);
});
it('속성이 모두 지워진 div 도 속성 없는 div 가 된다 — 경계는 자리(빈 문단 바로 뒤)로 알아본다 (화면 · 앱이 함께 보는 알려진 모양)', () => {
for (const div of [
'<div style="">',
'<div style="position: absolute">',
'<div class="sig" data-x="1">',
]) {
expect(
clean(`<p style="margin: 0"><br></p>${div}<p>서명</p></div>`),
).toBe('<p style="margin:0"><br /></p><div><p>서명</p></div>');
}
});
});
});
@@ -0,0 +1,65 @@
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { sanitizeStoredHtml } from './sanitize-stored-html';
describe('sanitizeStoredHtml — 저장해 두는 짧은 서식 글(서명 · 부재중 응답)의 정화', () => {
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const image = `<img src="/api/mail-signature/images/${IMAGE_ID}" width="96">`;
it('보내는 정화를 거친다 — 스크립트 · 이벤트 속성은 지우고, 그림은 넘겨준 서명 그림만 남긴다', () => {
expect(
sanitizeStoredHtml(
`<p onclick="x()"><strong>휴가</strong><script>x()</script></p>${image}`,
{ maxLength: null },
),
).toEqual({ ok: true, html: '<p><strong>휴가</strong></p>' });
expect(
sanitizeStoredHtml(`<p>서명</p>${image}`, {
maxLength: null,
signatureImageIds: new Set([IMAGE_ID]),
}),
).toEqual({
ok: true,
html: `<p>서명</p><img src="/api/mail-signature/images/${IMAGE_ID}" width="96" />`,
});
});
it('없거나 비었거나 정화해서 남는 것이 없으면 null', () => {
for (const html of [null, undefined, '', '<script>x()</script>', ' ']) {
expect(sanitizeStoredHtml(html, { maxLength: 10 })).toEqual({
ok: true,
html: null,
});
}
});
it('정화 뒤 길이는 maxLength 일 때만 본다 — 정화 전 길이 · 파서 비용은 늘 본다', () => {
const long = `<p>${'가'.repeat(10_000)}</p>`;
expect(sanitizeStoredHtml(long, { maxLength: 10_000 })).toEqual({
ok: false,
problem: 'too-long',
length: 10_007,
});
expect(sanitizeStoredHtml(long, { maxLength: null })).toEqual({
ok: true,
html: long,
});
expect(
sanitizeStoredHtml(`<p>${'가'.repeat(10_000 - 7)}</p>`, {
maxLength: 10_000,
}),
).toMatchObject({ ok: true });
expect(
sanitizeStoredHtml('x'.repeat(MAX_BODY_HTML_LENGTH + 1), {
maxLength: null,
}),
).toEqual({
ok: false,
problem: 'too-long',
length: MAX_BODY_HTML_LENGTH + 1,
});
expect(
sanitizeStoredHtml('<b>'.repeat(166_666), { maxLength: null }),
).toMatchObject({ ok: false, problem: 'too-complex' });
});
});
@@ -0,0 +1,54 @@
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { checkHtmlParseBudget } from './html-parse-budget';
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
/**
* 저장해 두고 다시 쓰는 짧은 서식 글의 정화 (순수 함수) — 서명(계약 2절 "서명")과 부재중 응답의 서식 본문(16단계 — "부재중 자동 응답").
* 쓰기 화면의 서식 본문과 같은 규칙이다:
* 1. 정화 전 — 길이(MAX_BODY_HTML_LENGTH — 500,000자)와 파서 비용(html-parse-budget.ts — 정화가 이벤트 루프를 오래 붙잡지 않게)
* 2. 보내는 정화(sanitize-outgoing-html.ts) — 그림은 `signatureImageIds` 에 든 그 사람의 서명 그림만 남는다(없으면 그림 없음 — 부재중)
* 3. 정화 뒤 — `maxLength`(JS 문자열 길이)를 넘으면 실패. null 이면 보지 않는다(부재중을 꺼 둔 채 저장할 때)
* 비었거나 정화해서 남는 것이 없으면 null 이다. 실패는 부르는 쪽이 400 VAL_001 로 던진다(사유만 — 글은 로그에 남기지 않는다)
*/
/** too-long: 정화 전 · 뒤의 길이 한도 초과, too-complex: 파서 비용 한도 초과(깊은 중첩 등) */
export type StoredHtmlProblem = 'too-long' | 'too-complex';
export type StoredHtmlResult =
| { readonly ok: true; readonly html: string | null }
| {
readonly ok: false;
readonly problem: StoredHtmlProblem;
/** 걸린 글의 길이 (JS 문자열 길이) — 로그용 */
readonly length: number;
};
export interface StoredHtmlOptions {
/** 정화한 뒤의 최대 길이 — null 이면 보지 않는다 */
readonly maxLength: number | null;
/** 남겨도 되는 서명 그림 id — 없으면 그림을 하나도 남기지 않는다 */
readonly signatureImageIds?: ReadonlySet<string>;
}
export function sanitizeStoredHtml(
html: string | null | undefined,
options: StoredHtmlOptions,
): StoredHtmlResult {
if (!html) return { ok: true, html: null };
if (html.length > MAX_BODY_HTML_LENGTH) {
return { ok: false, problem: 'too-long', length: html.length };
}
if (!checkHtmlParseBudget(html).ok) {
return { ok: false, problem: 'too-complex', length: html.length };
}
const sanitized = sanitizeOutgoingHtml(
html,
options.signatureImageIds
? { signatureImageIds: options.signatureImageIds }
: {},
);
if (options.maxLength !== null && sanitized.length > options.maxLength) {
return { ok: false, problem: 'too-long', length: sanitized.length };
}
return { ok: true, html: sanitized === '' ? null : sanitized };
}
@@ -11,11 +11,14 @@ import type {
import {
FILTER_SOURCE_FORMAT,
FILTER_SOURCE_FORMAT_WITH_BLOCKS,
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
LATEST_FILTER_SOURCE_FORMAT,
SAME_AS_INTERNAL_REPLY,
filterRulesVersion,
filterSourceLine,
readFilterSource,
usesRichVacation,
type FilterScriptSource,
} from './filter-source';
@@ -46,6 +49,8 @@ const vacation: MailVacation = {
toDate: null,
subject: '부재중 ${x} "따옴표"',
textBody: '첫 줄\r\n둘째 줄\n\t탭',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
const source: FilterScriptSource = {
@@ -186,7 +191,9 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
});
it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
expect(
readFilterSource(
scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })),
@@ -422,6 +429,153 @@ describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => {
});
});
describe('부재중의 서식 본문 · 회사 밖 응답(판 3 — 16단계)', () => {
const rich: MailVacation = {
...vacation,
htmlBody:
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
};
const richSource: FilterScriptSource = { ...source, vacation: rich };
const formatOf = (value: FilterScriptSource) =>
(
JSON.parse(
filterSourceLine(value).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>
).v;
const storedV3 = (overrides: Record<string, unknown> = {}) =>
stored({
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
vacation: rich,
blocked: [],
...overrides,
});
it('새 칸을 쓴 직원만 판 3 — 서식 본문 · 사내와 다른 회사 밖 응답 · 써 둔 회사 밖 글 가운데 하나라도. 아니면 판 1 · 2 그대로', () => {
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
expect(formatOf(source)).toBe(FILTER_SOURCE_FORMAT);
expect(usesRichVacation(vacation)).toBe(false);
for (const changed of [
{ ...vacation, htmlBody: '<p>휴가</p>' },
{ ...vacation, external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' } },
{
...vacation,
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'separate' },
},
// 사내와 같은 응답이어도 써 둔 회사 밖 글이 있으면 남겨야 한다
{
...vacation,
external: { ...SAME_AS_INTERNAL_REPLY, textBody: '써 둔 글' },
},
] as MailVacation[]) {
expect(usesRichVacation(changed)).toBe(true);
expect(formatOf({ ...source, vacation: changed })).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
}
// 판 3 은 차단 목록 칸을 늘 담는다(비었어도) — 차단이 있으면 그대로
const v3 = JSON.parse(
filterSourceLine(richSource).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>;
expect(v3.blocked).toEqual([]);
expect(v3.vacation).toEqual(rich);
});
it('판 3 을 그대로 읽는다 — 차단 목록도. 판 1 · 2 는 글만 · 사내와 같은 응답으로', () => {
const blocked: BlockedSender[] = [
{ address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' },
];
for (const value of [richSource, { ...richSource, blocked }]) {
expect(readFilterSource(scriptWith(filterSourceLine(value)))).toEqual({
status: 'ok',
source: value,
});
}
// 판 1 · 2 에 새 칸이 적혀 있어도 보지 않는다 — 그 판을 쓰는 서버는 그 칸을 모른다
for (const v of [FILTER_SOURCE_FORMAT, FILTER_SOURCE_FORMAT_WITH_BLOCKS]) {
expect(
sourceOf(scriptWithJson(stored({ v, vacation: rich, blocked: [] })))
?.vacation,
).toEqual({
...vacation,
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
});
}
});
it('판 3 의 서식 본문은 읽을 때 보내는 정화를 다시 거친다 — 다른 프로그램이 고친 원본의 스크립트 · 이벤트 속성 · 그림은 화면에 가지 않는다', () => {
const read = sourceOf(
scriptWithJson(
storedV3({
vacation: {
...rich,
htmlBody:
'<p onclick="x()">휴가<script>alert(1)</script></p><img src="https://evil.example/x.png">',
external: {
...rich.external,
htmlBody: '<a href="javascript:x()">링크</a><b>굵게</b>',
},
},
}),
),
);
expect(read?.vacation.htmlBody).toBe('<p>휴가</p>');
expect(read?.vacation.external.htmlBody).toBe(
'<span>링크</span><b>굵게</b>',
);
// 정화해서 비면 null — 글만
expect(
sourceOf(
scriptWithJson(
storedV3({ vacation: { ...rich, htmlBody: '<script>x()</script>' } }),
),
)?.vacation.htmlBody,
).toBeNull();
});
it.each<[string, Record<string, unknown>]>([
['회사 밖 응답 없음', { ...rich, external: undefined }],
['회사 밖 응답이 객체가 아님', { ...rich, external: 'same' }],
[
'모르는 고르기',
{ ...rich, external: { ...rich.external, mode: 'internal' } },
],
[
'회사 밖 제목이 글이 아님',
{ ...rich, external: { ...rich.external, subject: 1 } },
],
[
'회사 밖 글 칸 없음',
{ ...rich, external: { mode: 'same', subject: null, htmlBody: null } },
],
['서식 본문이 글이 아님', { ...rich, htmlBody: 1 }],
['서식 본문 칸 없음', { ...rich, htmlBody: undefined }],
[
'정화할 수 없는 서식(깊은 중첩)',
{ ...rich, htmlBody: '<b>'.repeat(166_666) },
],
])('판 3 의 부재중이 계약에 맞지 않으면 깨진 원본 — %s', (_label, value) => {
expect(
readFilterSource(scriptWithJson(storedV3({ vacation: value }))),
).toEqual(BROKEN);
});
it('판 3 의 차단 목록은 판 2 와 같은 규칙 — 칸이 없으면 깨진 원본', () => {
expect(
readFilterSource(scriptWithJson(storedV3({ blocked: undefined }))),
).toEqual(BROKEN);
});
});
describe('filterRulesVersion — 규칙의 지문(version)', () => {
const rules = source.rules;
+122 -25
View File
@@ -1,5 +1,6 @@
import { createHash } from 'crypto';
import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
import { parseBlockTarget } from '@/modules/mail/mail-blocks';
import {
MAIL_FILTER_FIELDS,
@@ -8,6 +9,7 @@ import {
findFilterRulesProblem,
} from '@/modules/mail/mail-filters';
import {
MAIL_VACATION_EXTERNAL_MODES,
MAX_MAIL_BLOCKS,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
@@ -17,6 +19,7 @@ import type {
MailFilterCondition,
MailFilterRule,
MailVacation,
MailVacationExternal,
} from '@/modules/mail/mail.types';
import { toUtcIso } from './jmap.mapper';
@@ -43,12 +46,19 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
* - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다.
* 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획:
* 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다)
* - 3: 2 + 부재중의 서식 본문 `vacation.htmlBody` · 회사 밖 응답 `vacation.external` (16단계) — **새 칸을 쓴 직원만** 쓴다
* (usesRichVacation — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우). 판 3 은 `blocked` 를 늘 담는다(비었어도).
* 판 3 을 아는 서버는 판 1 · 2 를 "글만 · 사내와 같은 응답"으로 읽는다. 판 3 을 모르는 서버로 되돌리면 새 칸을 쓴 직원만
* 필터 · 부재중 · 차단 저장이 503 MAIL_001 로 막힌다 — 엔진의 응답(Sieve)은 그대로 돈다(계약 2절 "부재중 자동 응답"의 되돌리기 계획)
*/
export const FILTER_SOURCE_FORMAT = 1;
/** 차단 목록이 든 원본의 판 (9단계) */
export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2;
/** 부재중의 서식 본문 · 회사 밖 응답이 든 원본의 판 (16단계) */
export const FILTER_SOURCE_FORMAT_WITH_RICH_VACATION = 3;
/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */
export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS;
export const LATEST_FILTER_SOURCE_FORMAT =
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION;
/**
* 스크립트 글에서 원본을 읽은 결과
@@ -69,6 +79,14 @@ export interface FilterScriptSource {
readonly blocked: readonly BlockedSender[];
}
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답, 따로 써 둔 글 없음 (16단계 — 판 1 · 2 의 원본 · 새 칸이 없는 옛 요청도 이것) */
export const SAME_AS_INTERNAL_REPLY: MailVacationExternal = {
mode: 'same',
subject: null,
textBody: null,
htmlBody: null,
};
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
export const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -76,8 +94,25 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = {
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
/**
* 16단계의 새 칸을 쓰는가 — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우. 그때만 원본 판 3 으로 쓴다
* (새 칸을 쓰지 않은 직원은 판 1 · 2 그대로 — 판 3 을 모르는 서버로 되돌려도 막히지 않는다)
*/
export function usesRichVacation(vacation: MailVacation): boolean {
const { external } = vacation;
return (
vacation.htmlBody !== null ||
external.mode !== 'same' ||
external.subject !== null ||
external.textBody !== null ||
external.htmlBody !== null
);
}
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
rules: [],
@@ -102,7 +137,8 @@ function canonicalRule(rule: MailFilterRule): MailFilterRule {
};
}
function canonicalVacation(vacation: MailVacation): MailVacation {
/** 판 1 · 2 의 부재중 — 다섯 칸 */
function canonicalVacation(vacation: MailVacation) {
return {
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
@@ -112,6 +148,16 @@ function canonicalVacation(vacation: MailVacation): MailVacation {
};
}
/** 판 3 의 부재중 — 다섯 칸 + 서식 본문 · 회사 밖 응답 */
function canonicalRichVacation(vacation: MailVacation): MailVacation {
const { mode, subject, textBody, htmlBody } = vacation.external;
return {
...canonicalVacation(vacation),
htmlBody: vacation.htmlBody,
external: { mode, subject, textBody, htmlBody },
};
}
/** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */
export function canonicalRules(
rules: readonly MailFilterRule[],
@@ -130,24 +176,28 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
}
/**
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 가장 낮은 판으로 쓴다 — 부재중의 새 칸(16단계)을 쓰면 판 3,
* 아니고 차단한 주소가 있으면 판 2, 둘 다 아니면 판 1(blocked 없이) — 낮은 판만 아는 서버도 읽는다
*/
export function filterSourceLine(source: FilterScriptSource): string {
const base = {
rules: canonicalRules(source.rules),
vacation: canonicalVacation(source.vacation),
};
const rules = canonicalRules(source.rules);
const blocked = source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
}));
if (usesRichVacation(source.vacation)) {
return `${FILTER_SOURCE_MARKER}${JSON.stringify({
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
rules,
vacation: canonicalRichVacation(source.vacation),
blocked,
})}`;
}
const base = { rules, vacation: canonicalVacation(source.vacation) };
return `${FILTER_SOURCE_MARKER}${JSON.stringify(
source.blocked.length === 0
blocked.length === 0
? { v: FILTER_SOURCE_FORMAT, ...base }
: {
v: FILTER_SOURCE_FORMAT_WITH_BLOCKS,
...base,
blocked: source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
})),
},
: { v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, ...base, blocked },
)}`;
}
@@ -252,8 +302,9 @@ function readDate(raw: unknown): string | null | undefined {
}
/**
* 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다
* 부재중(판 1 · 2) — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다.
* 16단계의 칸은 처음 값이다 — 글만 · 사내와 같은 응답
*/
function toVacation(raw: unknown): MailVacation | null {
if (
@@ -273,9 +324,52 @@ function toVacation(raw: unknown): MailVacation | null {
toDate,
subject: raw.subject,
textBody: raw.textBody,
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
}
/**
* 원본의 서식 본문 — null 이면 null, 글이면 보내는 정화(그림 없음)를 **다시** 거친 값(비면 null), 정화할 수 없는 글(길이 · 파서 비용)이면 undefined(깨짐).
* 다시 정화하는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본의 HTML 이 화면 · 받는 사람에게 그대로 가지 않게 — 우리가 쓴 값은 이미 정화한 값이라 그대로다
*/
function readVacationHtml(raw: unknown): string | null | undefined {
if (raw === null) return null;
if (typeof raw !== 'string') return undefined;
const sanitized = sanitizeStoredHtml(raw, { maxLength: null });
return sanitized.ok ? sanitized.html : undefined;
}
/** 회사 밖 응답(판 3) — 고르기는 셋 가운데 하나, 글 칸은 글 또는 null. 어긋나면 null(깨짐) */
function toVacationExternal(raw: unknown): MailVacationExternal | null {
if (
!isRecord(raw) ||
!isOneOf(raw.mode, MAIL_VACATION_EXTERNAL_MODES) ||
!isNullableString(raw.subject) ||
!isNullableString(raw.textBody)
) {
return null;
}
const htmlBody = readVacationHtml(raw.htmlBody);
if (htmlBody === undefined) return null;
return {
mode: raw.mode,
subject: raw.subject,
textBody: raw.textBody,
htmlBody,
};
}
/** 부재중(판 3) — 판 1 · 2 의 다섯 칸 + 서식 본문 · 회사 밖 응답. 어긋나면 null(깨짐) */
function toRichVacation(raw: unknown): MailVacation | null {
const base = toVacation(raw);
if (base === null || !isRecord(raw)) return null;
const htmlBody = readVacationHtml(raw.htmlBody);
const external = toVacationExternal(raw.external);
if (htmlBody === undefined || external === null) return null;
return { ...base, htmlBody, external };
}
/**
* 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각.
* 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다
@@ -313,8 +407,9 @@ const BROKEN: FilterSourceReading = { status: 'broken' };
/**
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
* 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken.
* 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 · 3 은 차단 목록도,
* 판 3 은 부재중의 서식 본문(다시 정화) · 회사 밖 응답도) — 어긋나면 broken.
* 판 1 은 차단 없음, 판 1 · 2 는 부재중이 글만 · 사내와 같은 응답이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
*/
export function readFilterSource(script: string): FilterSourceReading {
const line = findSourceLine(script);
@@ -337,7 +432,8 @@ export function readFilterSource(script: string): FilterSourceReading {
}
if (
(format !== FILTER_SOURCE_FORMAT &&
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) ||
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS &&
format !== FILTER_SOURCE_FORMAT_WITH_RICH_VACATION) ||
!Array.isArray(parsed.rules) ||
parsed.rules.length > MAX_MAIL_FILTER_RULES
) {
@@ -353,12 +449,13 @@ export function readFilterSource(script: string): FilterSourceReading {
if (findFilterRulesProblem(rules)) return BROKEN;
const blocked =
format === FILTER_SOURCE_FORMAT_WITH_BLOCKS
? toBlocked(parsed.blocked)
: [];
format === FILTER_SOURCE_FORMAT ? [] : toBlocked(parsed.blocked);
if (blocked === null) return BROKEN;
const vacation = toVacation(parsed.vacation);
const vacation =
format === FILTER_SOURCE_FORMAT_WITH_RICH_VACATION
? toRichVacation(parsed.vacation)
: toVacation(parsed.vacation);
return vacation
? { status: 'ok', source: { rules, vacation, blocked } }
: BROKEN;
@@ -190,6 +190,81 @@ describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword —
expect(failure?.code).toBe('MAIL_001');
expect(JSON.stringify(failure?.context)).not.toContain('secret details');
});
it('휴대폰 바꾸기(16단계) — 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청, updated → replaced · forbidden → code-rejected · 결과를 모르면 unknown', async () => {
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{ updated: { singleton: null } },
's',
]),
);
await expect(
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
).resolves.toBe('replaced');
expect(lastCalls(callAccountChange)).toEqual([
[
'x:AccountPassword/set',
{
accountId: 'x1',
update: {
singleton: {
currentSecret: 'pw',
otpAuth: { otpCode: '123456', otpUrl: OTPAUTH },
},
},
},
's',
],
]);
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{
notUpdated: {
singleton: {
type: 'forbidden',
description: 'Current secret is incorrect.',
},
},
},
's',
]),
);
await expect(
provider.replaceTwoFactor(ARA, 'pw', '000000', OTPAUTH),
).resolves.toBe('code-rejected');
const warn = jest.spyOn(Logger.prototype, 'warn');
callAccountChange.mockResolvedValueOnce({
kind: 'unknown',
detail: 'timeout',
});
await expect(
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
).resolves.toBe('unknown');
const logged = warn.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('가를 수 없어 MAIL_001');
expect(logged).not.toMatch(/123456|otpauth|pw\b/);
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{ notUpdated: { singleton: { type: 'serverFail' } } },
's',
]),
);
expect(
(await failureOf(provider.replaceTwoFactor(ARA, 'pw', '1', OTPAUTH)))
?.context,
).toMatchObject({
reason: 'two-factor-replace-refused',
jmapType: 'serverFail',
});
});
});
describe('앱 비밀번호', () => {
@@ -8,6 +8,7 @@ import type {
CreatedAppPassword,
TwoFactorDisableResult,
TwoFactorEnableResult,
TwoFactorReplaceResult,
} from '@/modules/account-security/account-security.provider';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
@@ -59,6 +60,8 @@ function toIso(value: unknown): string | null {
* - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나
* 이미 켜져 있으면(지금 코드를 요구) forbidden
* - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect."
* - 휴대폰 바꾸기(16단계): 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청 → updated(옛 비밀은 바로 죽는다).
* 코드 없음 · 틀린 코드 · 틀린 비밀번호 모두 forbidden (2026-11-10 개발 엔진 확인)
* - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다
* 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만)
*/
@@ -139,6 +142,33 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
}
async replaceTwoFactor(
user: string,
currentPassword: string,
otpCode: string,
otpauthUrl: string,
): Promise<TwoFactorReplaceResult> {
// 켠 채 지금 코드와 새 주소를 한 요청으로 — 엔진이 지금 코드를 확인하고 새 비밀로 바꾼다(옛 비밀은 바로 죽는다 — 2026-11-10 확인)
const reply = await this.jmap.callAccountChange(user, (accountId) => [
[
PASSWORD_SET,
{
accountId,
update: {
[PASSWORD_OBJECT_ID]: {
currentSecret: currentPassword,
otpAuth: { otpCode, otpUrl: otpauthUrl },
},
},
},
's',
],
]);
const outcome = this.passwordUpdate(user, reply, 'two-factor-replace');
if (outcome === 'updated') return 'replaced';
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
}
async listAppPasswords(user: string): Promise<AppPasswordRecord[]> {
const responses = await this.jmap.callAccount(user, (accountId) => [
[
@@ -251,7 +281,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
): 'updated' | 'forbidden' | 'unknown' {
if (reply.kind === 'unknown') {
this.logger.warn(
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
);
return 'unknown';
}
@@ -275,7 +305,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
if (failure?.type === 'forbidden') return 'forbidden';
if (!failure) {
this.logger.warn(
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`,
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}`,
);
return 'unknown';
}
@@ -285,6 +315,13 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
});
}
/** 결과를 모를 때 부르는 쪽이 하는 일 — 켜기 · 끄기는 다시 읽어 맞추고, 휴대폰 바꾸기는 가릴 수 없어 MAIL_001 로 답한다 */
private unknownFollowUp(call: string): string {
return call === 'two-factor-replace'
? '엔진이 otpUrl 을 가려 다시 읽어도 가를 수 없어 MAIL_001 로 답합니다'
: '엔진을 다시 읽어 맞춥니다';
}
/** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */
private createRefusal(error: JmapError): BusinessException {
if (error.type === 'overQuota') {
@@ -674,6 +674,147 @@ describe('JmapContactsProvider (엔진 주소록)', () => {
expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/);
});
it('가져오기(allowPartialCreate) — 받지 않은 만들기는 던지지 않고 refusedKeys 로, 받은 것만 createdIds · 카드 모양 (warn 로그는 개수 · 종류만)', async () => {
engine.setReply = (args) => {
const create = args.create as Record<string, Record<string, unknown>>;
expect(Object.keys(create)).toEqual(['import-0', 'import-1']);
return [
'ContactCard/set',
{
newState: 's11',
created: { 'import-0': { id: 'n7' } },
notCreated: {
'import-1': {
type: 'invalidProperties',
description: 'Invalid 박지훈 jihoon@x',
},
},
updated: { g: null },
},
'w',
];
};
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{
create: [
{
kind: 'contact',
key: 'import-0',
uid: 'urn:uuid:a',
fields: { ...newPerson, name: '받음' },
},
{
kind: 'contact',
key: 'import-1',
uid: 'urn:uuid:b',
fields: { ...newPerson, name: '박지훈' },
},
],
update: [
{
kind: 'group',
id: 'g',
memberUids: ['urn:uuid:a', 'urn:uuid:b'],
},
],
destroy: [],
},
{ allowPartialCreate: true },
);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n7']]));
expect(outcome.refusedKeys).toEqual(new Set(['import-1']));
expect([...outcome.cards.keys()]).toEqual(['n7']);
expect(outcome.version).toBe(version('book', 's11'));
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('받지 않음 1/2');
expect(logged).toContain('jmapType=invalidProperties');
expect(logged).not.toMatch(/박지훈|jihoon|Invalid|ara\.kim@/);
});
it('가져오기라도 고치기 · 지우기의 거절은 지금처럼 던진다 · 모두 받으면 refusedKeys 는 비어 있다', async () => {
engine.setReply = () => [
'ContactCard/set',
{
newState: 's11',
created: { 'import-0': { id: 'n7' } },
notUpdated: { g: { type: 'invalidProperties' } },
},
'w',
];
const changes: AddressBookChanges = {
create: [
{
kind: 'contact',
key: 'import-0',
uid: 'urn:uuid:a',
fields: newPerson,
},
],
update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }],
destroy: [],
};
const error = await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), changes, {
allowPartialCreate: true,
}),
);
expect(error.code).toBe('VAL_001');
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...changes, update: [] },
{ allowPartialCreate: true },
);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.refusedKeys).toEqual(new Set());
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n1']]));
});
it('한꺼번에 지우기(withoutOriginals) — 지울 카드는 id 만 읽어 있는지 보고, 원본은 주지 않는다', async () => {
engine.add({
id: 'c1',
uid: 'urn:uuid:c1',
name: { full: '하나' },
photos: { p: { href: 'https://x.example/p.jpg' } },
} as JmapContactCard);
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...noChanges, destroy: ['c1'] },
{ withoutOriginals: true },
);
const [read] = engine.requests;
expect(read?.calls).toEqual([
[
'ContactCard/get',
{ accountId: 'acc', ids: ['c1'], properties: ['id'] },
'd',
],
]);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.removed.size).toBe(0);
expect(engine.cardOf('c1')).toBeUndefined();
// 그 판에 없는 카드면 conflict (보내지 않는다)
const missing = await provider.writeAddressBook(
ARA,
version('book', 's11'),
{ ...noChanges, destroy: ['c1'] },
{ withoutOriginals: true },
);
expect(missing).toEqual({ kind: 'conflict' });
});
it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => {
const original = {
source: JSON.stringify({
@@ -7,6 +7,7 @@ import type {
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
AddressBookWriteOptions,
AddressBookWriteOutcome,
ContactsProvider,
RemovedCardOriginal,
@@ -49,6 +50,9 @@ const READ_PAGE_SIZE = 500;
*/
export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000;
/** 원본이 필요 없는 지우기(한꺼번에 지우기 — 16단계)에서 지울 카드가 있는지만 볼 때 읽는 칸 (id 는 늘 온다) */
const EXISTENCE_PROPERTIES = ['id'];
/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */
const CARD_PROPERTIES = [
'uid',
@@ -118,6 +122,8 @@ function pickAddressBook(books: readonly JmapAddressBook[]): string | null {
* 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy.
* 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/<uid>` 패치를 받지 않는다 — 그래서 uid 는 만들 때
* 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인)
* - 가져오기(16단계 — allowPartialCreate): 받지 않은 만들기는 던지지 않고 refusedKeys 로 알린다(받은 것은 들어갔다). 한 요청에 300명 90ms(2026-11-10 확인),
* 엔진의 한 요청 상한(maxObjectsInSet 500)은 서비스가 맞춘다. 한꺼번에 지우기(withoutOriginals)는 지울 카드를 id 만 읽는다
* - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만)
*/
@Injectable()
@@ -211,6 +217,7 @@ export class JmapContactsProvider implements ContactsProvider {
user: string,
version: string,
changes: AddressBookChanges,
options: AddressBookWriteOptions = {},
): Promise<AddressBookWriteOutcome> {
const { addressBookId, state } = decodeVersion(version);
if (changes.create.length > 0 && addressBookId === null) {
@@ -230,6 +237,7 @@ export class JmapContactsProvider implements ContactsProvider {
change.kind === 'contact' ? [change.id] : [],
),
changes.destroy,
options.withoutOriginals === true,
);
if (current === null) return { kind: 'conflict' };
const removed = current.originals;
@@ -287,8 +295,14 @@ export class JmapContactsProvider implements ContactsProvider {
this.warnBusy(user, 'set-error');
return { kind: 'busy', removed };
}
// 가져오기(16단계) — 받지 않은 만들기는 던지지 않고 적는다 (고치기 · 지우기의 거절은 아래에서 지금처럼 던진다)
const refusedKeys = options.allowPartialCreate
? this.refusedCreates(user, set, changes)
: new Set<string>();
const refusal =
firstSetError(set.notCreated) ??
(options.allowPartialCreate
? undefined
: firstSetError(set.notCreated)) ??
firstSetError(set.notUpdated) ??
firstSetError(set.notDestroyed);
if (refusal) {
@@ -318,6 +332,7 @@ export class JmapContactsProvider implements ContactsProvider {
const createdIds = new Map<string, string>();
for (const card of changes.create) {
if (refusedKeys.has(card.key)) continue;
const id = set.created?.[card.key]?.id;
if (typeof id !== 'string' || id === '') {
throw new BusinessException('MAIL_001', {
@@ -337,11 +352,35 @@ export class JmapContactsProvider implements ContactsProvider {
createdIds,
cards,
removed,
...(options.allowPartialCreate ? { refusedKeys } : {}),
};
}
/**
* 엔진이 받지 않은 만들기의 key (가져오기 — 16단계). 우리가 보낸 key 만 센다. 있으면 warn 로그 — 개수 · 엔진 오류 종류만
* (카드 내용 · 엔진 설명 문장은 남기지 않는다)
*/
private refusedCreates(
user: string,
set: JmapSetResult,
changes: AddressBookChanges,
): Set<string> {
const sent = new Set(changes.create.map((card) => card.key));
const refused = new Set(
Object.keys(set.notCreated ?? {}).filter((key) => sent.has(key)),
);
const first = firstSetError(set.notCreated);
if (refused.size > 0) {
this.logger.warn(
`[${mapJmapErrorType(first?.type)}]${traceTag()} 엔진이 가져오기의 일부 연락처를 받지 않았습니다 — 받은 것만 넣었습니다: user=${maskEmail(user)}, jmapType=${first?.type ?? 'unknown'}, 받지 않음 ${refused.size}/${changes.create.length}`,
);
}
return refused;
}
/**
* 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나.
* withoutOriginals 면 지울 카드는 있는지만 본다(id 만 — 사진이 든 카드 500장을 통째로 읽지 않게, 원본은 주지 않는다).
* 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다)
*/
private async readCurrentCards(
@@ -349,6 +388,7 @@ export class JmapContactsProvider implements ContactsProvider {
state: string,
updateIds: readonly string[],
destroyIds: readonly string[],
withoutOriginals: boolean,
): Promise<{
cards: ReadonlyMap<string, Record<string, unknown>>;
originals: ReadonlyMap<string, RemovedCardOriginal>;
@@ -370,8 +410,12 @@ export class JmapContactsProvider implements ContactsProvider {
? [
[
'ContactCard/get',
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두
{ accountId, ids: [...destroyIds], properties: null },
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두 (원본이 필요 없으면 있는지만)
{
accountId,
ids: [...destroyIds],
properties: withoutOriginals ? EXISTENCE_PROPERTIES : null,
},
'd',
] as JmapInvocation,
]
@@ -396,9 +440,11 @@ export class JmapContactsProvider implements ContactsProvider {
if (updating === null || destroying === null) return null;
return {
cards: updating,
originals: new Map(
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
),
originals: withoutOriginals
? new Map()
: new Map(
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
),
};
}
@@ -7,7 +7,10 @@ import type {
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
SAME_AS_INTERNAL_REPLY,
filterRulesVersion,
readFilterSource,
type FilterScriptSource,
@@ -57,6 +60,8 @@ const vacationOn: MailVacation = {
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
interface FakeScript {
@@ -139,8 +144,12 @@ class FakeSieveEngine {
),
};
/** 회사 메일 도메인 설정 (MAIL_SETTINGS 의 domain) — 부재중의 회사 밖 응답을 가를 때 */
readonly settings: { domain: string | null } = { domain: 'arasystem.test' };
readonly store = new JmapFilterScriptStore(
this.client as unknown as JmapClient,
this.settings,
);
/** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */
@@ -341,12 +350,15 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: true,
});
// 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값)
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
isActive: true,
});
expect(engine.recorded[0]).toEqual({
@@ -392,6 +404,8 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
toDate: '2026-11-07T15:00:00.000Z',
subject: '',
textBody: '',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
@@ -532,7 +546,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
context: {
reason: 'filter-source-newer',
format: 99,
supportedFormat: 2,
supportedFormat: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
},
});
}
@@ -1060,6 +1074,55 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.ours()?.isActive).toBe(true);
});
it('회사 밖 따로 쓰기 · 서식 본문(16단계) — 회사 메일 도메인으로 봉투 보낸 사람을 가르는 글을 올리고, 원본은 판 3', async () => {
const engine = new FakeSieveEngine();
engine.settings.domain = 'arasystem.it.kr';
const rich: MailVacation = {
...vacationOn,
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: null,
},
};
await expect(engine.store.setVacation(ARA, rich)).resolves.toEqual({
...rich,
isActive: true,
});
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
expect(text).toContain(
'if envelope :domain :is "from" "arasystem.it.kr" {',
);
expect(text).toContain('multipart/alternative');
expect(text).toContain(':handle "ara-external-');
expect(text.split('\r\n')[1]).toContain(
`${FILTER_SOURCE_MARKER}{"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION},`,
);
expect(engine.ourSource()?.vacation).toEqual(rich);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...rich,
isActive: true,
});
});
it('회사 메일 도메인 설정이 없으면 직원 주소의 도메인으로 가른다 (로그인한 직원은 늘 그 도메인이다)', async () => {
const engine = new FakeSieveEngine();
engine.settings.domain = null;
await engine.store.setVacation(ARA, {
...vacationOn,
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' },
});
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
expect(text).toContain(
'if envelope :domain :is "from" "arasystem.test" {',
);
expect(text).not.toContain('else');
});
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
BlockedSender,
MailBlockAdded,
@@ -121,7 +122,10 @@ interface ScriptSnapshot extends ScriptView {
readonly newerFormat: number | null;
}
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */
/**
* 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수, 부재중의 켬 · 서식 있음 · 회사 밖 고르기만
* (규칙의 글 · 차단한 주소 · 부재중 글은 남기지 않는다)
*/
function scriptSummary(
script: BuiltFilterScript,
source: FilterScriptSource,
@@ -131,10 +135,17 @@ function scriptSummary(
rules: source.rules.length,
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
vacation: source.vacation.isEnabled,
vacationHtml: source.vacation.htmlBody !== null,
vacationExternal: source.vacation.external.mode,
blocked: source.blocked.length,
};
}
/** 사용자 주소의 도메인 (소문자) — 회사 메일 도메인 설정이 없을 때만 쓴다(로그인한 직원은 늘 그 도메인이다) */
function domainOf(user: string): string {
return user.slice(user.lastIndexOf('@') + 1).toLowerCase();
}
function toFilterSet(view: ScriptView): MailFilterSet {
return {
rules: canonicalRules(view.source.rules),
@@ -175,7 +186,12 @@ function toBlockList(view: ScriptView): MailBlockList {
export class JmapFilterScriptStore {
private readonly logger = new Logger(JmapFilterScriptStore.name);
constructor(private readonly jmap: JmapClient) {}
constructor(
private readonly jmap: JmapClient,
/** 회사 메일 도메인 — 부재중의 회사 밖 응답(16단계)을 봉투 보낸 사람의 도메인으로 가를 때 */
@Inject(MAIL_SETTINGS)
private readonly settings: Pick<MailSettings, 'domain'>,
) {}
/** 지금 규칙 · 지문 · 동작하는지 */
async getFilters(user: string): Promise<MailFilterSet> {
@@ -407,7 +423,10 @@ export class JmapFilterScriptStore {
if (next.blocked.length > 0 && junkMailbox === null) {
junkMailbox = await this.junkMailbox(user);
}
const script = buildFilterScript(next, { junkMailbox });
const script = buildFilterScript(next, {
junkMailbox,
internalDomain: this.settings.domain ?? domainOf(user),
});
if (
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
@@ -429,7 +448,7 @@ export class JmapFilterScriptStore {
const saved = await this.save(user, current, blobId, script, next);
if (saved !== null) {
this.logger.debug(
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`,
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}(서식 ${next.vacation.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${next.vacation.external.mode}), 차단 ${next.blocked.length}개, ${attempt}번째`,
);
return { source: next, isActive: saved.isActive };
}
@@ -18,6 +18,7 @@ import {
import type { MailSettings } from '@/modules/mail/mail.settings';
import type {
DraftContent,
MailVacation,
MailWatch,
MailWatchListener,
OutgoingMessage,
@@ -198,7 +199,10 @@ function fakeClient(
client as unknown as JmapClient,
cache,
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
new JmapFilterScriptStore(
client as unknown as JmapClient,
overrides.settings ?? settings,
),
new JmapFilterRunner(client as unknown as JmapClient),
new JmapConversations(client as unknown as JmapClient),
),
@@ -8024,12 +8028,15 @@ describe('JmapMailProvider', () => {
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
};
const vacation = {
// 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값)
const vacation: MailVacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
};
beforeEach(() => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
@@ -133,6 +133,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
isActive: true,
};
filterScripts = {
@@ -899,12 +901,15 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
match: 'always',
actions: { forwardTo: `doyun.lee@${DOMAIN}` },
});
// 운영자의 안내 글은 글만 · 회사 안팎 모두에게 (16단계 칸은 처음 값)
expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
});
filterScripts.getFilters.mockResolvedValue({
@@ -912,12 +917,20 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
version: 'v2',
isActive: true,
});
const external = {
mode: 'separate' as const,
subject: 'Left the company',
textBody: 'Please contact doyun.',
htmlBody: null,
};
filterScripts.getVacation.mockResolvedValue({
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: '<p>퇴사했습니다.</p>',
external,
isActive: true,
});
await expect(engine.getLockRules(ARA)).resolves.toEqual({
@@ -929,10 +942,16 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
rules: [saved.rules[1]],
version: 'v2',
});
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(
ARA,
expect.objectContaining({ isEnabled: false, subject: '부재 안내' }),
);
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 (isActive 는 넘기지 않는다)
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(ARA, {
isEnabled: false,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: '<p>퇴사했습니다.</p>',
external,
});
});
it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => {
@@ -529,12 +529,20 @@ export class JmapOpsEngine implements OpsEngine {
});
}
if (rules.autoReply !== null) {
// 운영자의 안내 글 — 글만, 회사 안팎 모두에게 (16단계의 서식 본문 · 회사 밖 응답은 쓰지 않는다)
await this.filterScripts.setVacation(email, {
isEnabled: true,
fromDate: null,
toDate: null,
subject: rules.autoReply.subject,
textBody: rules.autoReply.body,
htmlBody: null,
external: {
mode: 'same',
subject: null,
textBody: null,
htmlBody: null,
},
});
}
}
@@ -549,12 +557,15 @@ export class JmapOpsEngine implements OpsEngine {
}
const vacation = await this.filterScripts.getVacation(email);
if (vacation.isEnabled) {
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 둔다
await this.filterScripts.setVacation(email, {
isEnabled: false,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject,
textBody: vacation.textBody,
htmlBody: vacation.htmlBody,
external: vacation.external,
});
}
}
+4 -1
View File
@@ -355,7 +355,8 @@ export function toUsage(quota: JmapQuota): MailUsage {
* 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다
* (4단계에 저장한 값 이어받기 — jmap-filter-script.ts).
* 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다.
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null, external: 사내와 같은 응답 }` 가 된다.
* 엔진의 서식 본문(htmlBody)은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값 — filter-source.ts 의 SAME_AS_INTERNAL_REPLY 와 같다)
*/
export function toVacation(
response: JmapVacationResponse | undefined,
@@ -366,6 +367,8 @@ export function toVacation(
toDate: toUtcIso(response?.toDate),
subject: response?.subject ?? '',
textBody: response?.textBody ?? '',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
};
}
@@ -1,5 +1,6 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
alternativeMimeEntity,
base64TextBody,
decodeHeaderText,
encodeHeaderWords,
@@ -97,6 +98,68 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
});
});
describe('alternativeMimeEntity — 부재중 서식 본문 (16단계, 글 + 서식 두 벌)', () => {
/** 개체의 부분 — 경계로 나눠 머리 · base64 본문을 푼다 */
const partsOf = (entity: string) => {
const boundary = /boundary="([^"]+)"/.exec(entity)?.[1] ?? '';
return entity
.split(`--${boundary}`)
.slice(1, -1)
.map((part) => {
const [head, body] = part.replace(/^\r\n/, '').split('\r\n\r\n');
return {
head,
text: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString(
'utf8',
),
};
});
};
it('multipart/alternative — 글(text/plain) 다음 서식(text/html), 각 base64 · utf-8, 닫는 경계 뒤 줄바꿈 (엔진이 그대로 보낸 모양 — engine-facts E2)', () => {
const entity = alternativeMimeEntity(
'11월 7일까지 휴가입니다.\n급한 일은 이도윤 님께.',
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
);
const lines = entity.split('\r\n');
expect(lines.slice(0, 3)).toEqual([
'Content-Type: multipart/alternative; boundary="ara-vacation-alternative"',
'',
'--ara-vacation-alternative',
]);
expect(entity.endsWith('\r\n--ara-vacation-alternative--\r\n')).toBe(true);
expect(partsOf(entity)).toEqual([
{
head: 'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
text: '11월 7일까지 휴가입니다.\r\n급한 일은 이도윤 님께.',
},
{
head: 'Content-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
text: '<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
},
]);
});
it('ASCII 만 나오고 경계는 본문 줄과 겹치지 않는다 — 글 · 서식의 ${ · 역슬래시 · 따옴표 · 경계와 같은 글도 base64 안에만 있다', () => {
const entity = alternativeMimeEntity(
'${x} \\ "따옴표" --ara-vacation-alternative',
'<p>${x} --ara-vacation-alternative</p>',
);
expect(entity).toMatch(/^[\x20-\x7e\r\n]*$/);
expect(entity).not.toContain('${');
expect(entity).not.toContain('\\');
// 경계 줄은 셋뿐이다(여는 둘 · 닫는 하나)
expect(
entity.split('\r\n').filter((line) => line.startsWith('--')),
).toEqual([
'--ara-vacation-alternative',
'--ara-vacation-alternative',
'--ara-vacation-alternative--',
]);
});
});
/**
* 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정).
* 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값)
+31 -1
View File
@@ -1,6 +1,6 @@
/**
* 메일 머리글 · 본문의 글 모양 (순수 함수).
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문(서식이 있으면 글 + 서식 두 벌 — 16단계).
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts)
@@ -84,6 +84,36 @@ export function plainTextMimeEntity(text: string): string {
].join(CRLF);
}
/**
* 글 + 서식 두 벌의 경계 (RFC 2046 §5.1.1) — base64 글자에 없는 `-` 가 들어 있어 본문 줄과 겹치지 않는다.
* 늘 같은 값이라 같은 부재중이면 같은 스크립트 글이 된다 (다시 올리지 않고 쓴다 — jmap-filter-script.ts)
*/
const ALTERNATIVE_BOUNDARY = 'ara-vacation-alternative';
/**
* 글 + 서식 두 벌의 MIME 개체 (16단계 — 부재중 서식 본문) — `multipart/alternative` 에 글(text/plain) · 서식(text/html) 차례로, 각 base64.
* 서식을 읽지 못하는 메일 프로그램은 글만 보인다. 엔진이 이 개체를 그대로 보낸다(2026-11-10 개발 엔진 — engine-facts E2).
* ASCII 로만 나오므로 Sieve 문자열에 넣어도 `${` · 따옴표 · 역슬래시가 생기지 않는다(경계의 따옴표는 Sieve 쪽이 이스케이프한다)
*/
export function alternativeMimeEntity(text: string, html: string): string {
return [
`Content-Type: multipart/alternative; boundary="${ALTERNATIVE_BOUNDARY}"`,
'',
`--${ALTERNATIVE_BOUNDARY}`,
'Content-Type: text/plain; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
base64TextBody(text),
`--${ALTERNATIVE_BOUNDARY}`,
'Content-Type: text/html; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
base64TextBody(html),
`--${ALTERNATIVE_BOUNDARY}--`,
'',
].join(CRLF);
}
/**
* encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다
* (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다
@@ -4,13 +4,16 @@ import type {
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
SAME_AS_INTERNAL_REPLY,
filterSourceLine,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
import { buildFilterScript } from './sieve-script';
import { alternativeMimeEntity, plainTextMimeEntity } from './mime-text';
import { SIEVE_STRING_MAX_BYTES, buildFilterScript } from './sieve-script';
const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
@@ -38,6 +41,39 @@ const vacationOn: MailVacation = {
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '휴가입니다.\n급한 일은 이도윤 님께.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
/** 개발 엔진과 같은 회사 메일 도메인 */
const DOMAIN = 'arasystem.test';
/** Sieve 따옴표 문자열을 푼다 — `\"` · `\\` 만 */
const unquote = (quoted: string) => quoted.replace(/\\(["\\])/g, '$1');
/** 스크립트의 `set "이름" "값";` 들 — 이름 → 값 */
const variablesOf = (text: string): Map<string, string> =>
new Map(
[...text.matchAll(/set "(part\d+)" "((?:[^"\\]|\\.)*)";/g)].map(
([, name, value]) => [name, unquote(value)],
),
);
/** 스크립트의 vacation 명령들 — 태그 · MIME 개체(변수로 이었으면 이은 값) */
const vacationCommandsOf = (text: string) => {
const variables = variablesOf(text);
return [
...text.matchAll(
/vacation :mime (?::handle "([^"]*)" )?(?::subject "([^"]*)" )?"((?:[^"\\]|\\.)*)";/g,
),
].map(([, handle, subject, argument]) => ({
handle: handle ?? null,
subject: subject ?? null,
entity: unquote(argument).replace(
/\$\{(part\d+)\}/g,
(_whole, name: string) => variables.get(name) ?? '',
),
}));
};
/** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */
@@ -296,6 +332,236 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
expect(sourceOf(text)?.vacation).toEqual(off);
});
it('서식 본문이 있으면 글 + 서식 두 벌(multipart/alternative) — 사내와 같은 응답이면 봉투 검사 · :handle 없이 하나 (16단계)', () => {
const html =
'<p style="margin:0">휴가입니다. <strong>급한 일</strong>은 이도윤 님께.</p>';
const { text } = buildFilterScript({
rules: [],
vacation: {
...vacationOn,
fromDate: null,
toDate: null,
htmlBody: html,
},
blocked: [],
});
expect(linesOf(text)[2]).toBe('require ["vacation"];');
expect(text).not.toContain('envelope');
expect(text).not.toContain(':handle');
expect(vacationCommandsOf(text)).toEqual([
{
handle: null,
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
entity: alternativeMimeEntity(vacationOn.textBody, html),
},
]);
// 원본은 판 3 — 서식 본문을 쓴 직원만
expect(sourceOf(text)?.vacation.htmlBody).toBe(html);
expect(linesOf(text)[1]).toContain(
`"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION}`,
);
});
it('회사 밖 따로 쓰기 — 봉투 보낸 사람의 도메인으로 가르고(envelope :domain :is), 회사 밖 응답은 :handle 을 따로 둔다', () => {
const source: FilterScriptSource = {
rules: [],
vacation: {
...vacationOn,
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
},
blocked: [],
};
const { text } = buildFilterScript(source, { internalDomain: DOMAIN });
const lines = linesOf(text);
expect(lines[2]).toBe(
'require ["date", "envelope", "relational", "vacation"];',
);
expect(lines.slice(3, 6)).toEqual([
'if not header :matches "X-Spam-Score" "spam*" {',
' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {',
' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {',
]);
expect(lines[6]).toBe(
` if envelope :domain :is "from" "${DOMAIN}" {`,
);
expect(lines[7]).toMatch(/^ {8}vacation :mime :subject /);
expect(lines).toContain(' } else {');
expect(lines.slice(-5)).toEqual([' }', ' }', ' }', '}', '']);
const [internal, external] = vacationCommandsOf(text);
expect(internal).toEqual({
handle: null,
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
entity: plainTextMimeEntity(vacationOn.textBody),
});
expect(external.subject).toBe(`=?UTF-8?B?${b64('Out of office')}?=`);
expect(external.entity).toBe(
alternativeMimeEntity(
'I am away until Nov 7.',
'<p>I am away until <strong>Nov 7</strong>.</p>',
),
);
expect(external.handle).toMatch(/^ara-external-[A-Za-z0-9_-]{16}$/);
expect(sourceOf(text)?.vacation).toEqual(source.vacation);
});
it('회사 밖 :handle 은 응답 내용의 지문 — 같은 글이면 같고, 글이 바뀌면 바뀐다', () => {
const handleOf = (subject: string) =>
vacationCommandsOf(
buildFilterScript(
{
rules: [],
vacation: {
...vacationOn,
external: {
mode: 'separate',
subject,
textBody: 'away',
htmlBody: null,
},
},
blocked: [],
},
{ internalDomain: DOMAIN },
).text,
)[1].handle;
expect(handleOf('Away')).toBe(handleOf('Away'));
expect(handleOf('Away')).not.toBe(handleOf('Away!'));
});
it('회사 밖 보내지 않기 — 사내 도메인일 때만 답한다(else 없음), 회사 밖에 써 둔 글은 원본에만', () => {
const vacation: MailVacation = {
...vacationOn,
fromDate: null,
toDate: null,
external: {
mode: 'none',
subject: '써 둔 제목',
textBody: '써 둔 글',
htmlBody: null,
},
};
const { text } = buildFilterScript(
{ rules: [], vacation, blocked: [] },
{ internalDomain: DOMAIN },
);
expect(linesOf(text).slice(2)).toEqual([
'require ["envelope", "vacation"];',
'if not header :matches "X-Spam-Score" "spam*" {',
` if envelope :domain :is "from" "${DOMAIN}" {`,
expect.stringMatching(/^ {4}vacation :mime :subject /) as string,
'Content-Transfer-Encoding: base64',
'',
b64('휴가입니다.\r\n급한 일은 이도윤 님께.'),
'";',
' }',
'}',
'',
]);
expect(text).not.toContain('else');
expect(vacationCommandsOf(text)).toHaveLength(1);
expect(sourceOf(text)?.vacation.external).toEqual(vacation.external);
});
it('회사 밖 응답을 가르는데 회사 메일 도메인을 받지 못하면 만들지 않는다 — 꺼져 있거나 사내와 같으면 필요 없다', () => {
const external = {
mode: 'separate' as const,
subject: 'x',
textBody: 'y',
htmlBody: null,
};
const source = (isEnabled: boolean): FilterScriptSource => ({
rules: [],
vacation: { ...vacationOn, isEnabled, external },
blocked: [],
});
expect(() => buildFilterScript(source(true))).toThrow();
expect(() =>
buildFilterScript(source(true), { internalDomain: null }),
).toThrow();
expect(() => buildFilterScript(source(false))).not.toThrow();
expect(() =>
buildFilterScript({ rules: [], vacation: vacationOn, blocked: [] }),
).not.toThrow();
});
it('MIME 개체가 문자열 하나 한도(4,096바이트)를 넘으면 variables 조각(3,000바이트 이하 · 줄 끝에서 끊음)으로 나눠 잇는다 — 이으면 원래 개체 그대로', () => {
const textBody =
'휴가 중이라 메일을 늦게 확인합니다. 급한 일은 담당자에게 연락해 주세요. '
.repeat(14)
.slice(0, 600);
const html = `<div style="font-family: 'Malgun Gothic', sans-serif">${textBody
.split('. ')
.map(
(sentence) =>
`<p style="margin: 0"><strong>${sentence}.</strong> <a href="https://example.com/">담당자</a></p>`,
)
.join('')}</div>`;
const built = buildFilterScript(
{
rules: [],
vacation: {
...vacationOn,
textBody,
htmlBody: html,
external: {
mode: 'separate',
subject: 'Away',
textBody,
htmlBody: html,
},
},
blocked: [],
},
{ internalDomain: DOMAIN },
);
const variables = variablesOf(built.text);
expect(linesOf(built.text)[2]).toBe(
'require ["date", "envelope", "relational", "vacation", "variables"];',
);
// 사내 · 회사 밖 모두 나뉘었다 — 변수 이름은 스크립트 안에서 겹치지 않는다
expect(variables.size).toBeGreaterThanOrEqual(4);
expect([...variables.keys()]).toEqual(
[...variables.keys()].map((_name, index) => `part${index}`),
);
for (const piece of variables.values()) {
expect(Buffer.byteLength(piece, 'utf8')).toBeLessThanOrEqual(3_000);
// 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 가 없다
expect(piece.endsWith('\r\n')).toBe(true);
expect(piece.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/);
}
const [internal, external] = vacationCommandsOf(built.text);
expect(internal.entity).toBe(alternativeMimeEntity(textBody, html));
expect(external.entity).toBe(alternativeMimeEntity(textBody, html));
expect(built.longestStringBytes).toBeLessThanOrEqual(
SIEVE_STRING_MAX_BYTES,
);
// 사용자 글의 ${ 는 Sieve 에 없다 — 우리 변수만 펼친다
expect(
[...built.text.matchAll(/\$\{([^}]*)\}/g)].map(([, name]) => name),
).toEqual([...variables.keys()].map((name) => name));
});
it('문자열 하나 한도 안이면 지금처럼 한 문자열 — 글만인 응답(한글 600자)은 나누지 않는다', () => {
const built = buildFilterScript({
rules: [],
vacation: { ...vacationOn, textBody: '가'.repeat(600) },
blocked: [],
});
expect(built.text).not.toContain('variables');
expect(built.text).not.toContain('set "');
});
it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => {
const { text } = buildFilterScript({
rules: [],
+202 -12
View File
@@ -1,3 +1,5 @@
import { createHash } from 'crypto';
import type {
BlockedSender,
MailFilterCondition,
@@ -7,7 +9,11 @@ import type {
} from '@/modules/mail/mail.types';
import { filterSourceLine, type FilterScriptSource } from './filter-source';
import { toJmapUtcDate } from './jmap.mapper';
import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
import {
alternativeMimeEntity,
encodeHeaderWords,
plainTextMimeEntity,
} from './mime-text';
/**
* 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양):
@@ -22,10 +28,17 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* }
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
* 회사 밖 응답이 사내와 같으면 vacation 하나, 아니면(16단계) if envelope :domain :is "from" "<회사 도메인>" { 사내 } else { 회사 밖 } — 보내지 않기면 else 없음
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
* }
* ```
*
* - 부재중의 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다(16단계) — 답장도 봉투 주소로만 가므로(엔진 확인 — engine-facts E2)
* 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다. 도메인은 그 도메인만(`:is` — 하위 도메인은 회사 밖)
* - 부재중 응답의 본문은 MIME 개체 하나(서식이 있으면 multipart/alternative — 글 + 서식, 각 base64)다. 엔진은 Sieve 문자열 하나를 4,096바이트로 막으므로
* 그보다 길면 `variables` 로 3,000바이트 이하의 조각에 나눠 담고(`set "part0" "…";`) `vacation :mime "${part0}${part1}…"` 로 잇는다
* (엔진 확인 — 이은 답장의 글 · 서식이 원래와 바이트까지 같다). 조각은 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 는 문법 밖이다
*
* - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다.
* 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고,
* id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절)
@@ -90,6 +103,28 @@ export interface JunkMailboxTarget {
export interface FilterScriptOptions {
/** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */
readonly junkMailbox?: JunkMailboxTarget | null;
/**
* 회사 메일 도메인(`MAIL_DOMAIN`, 소문자) — 켠 부재중의 회사 밖 응답이 사내와 같지 않으면(16단계) 있어야 한다. 없으면 만들지 않는다(던진다)
*/
readonly internalDomain?: string | null;
}
/**
* 부재중 응답 MIME 개체를 나눌 조각 크기 (UTF-8 바이트) — 엔진의 문자열 하나 한도(SIEVE_STRING_MAX_BYTES)보다 넉넉히 작게.
* 2026-11-10 개발 엔진: 3,000바이트 조각을 이은 응답이 원래와 바이트까지 같았다 (engine-facts E2)
*/
const VACATION_CHUNK_BYTES = 3_000;
/** 회사 밖 응답의 :handle 앞부분 — 뒤에 응답 내용의 지문을 붙인다(내용이 바뀌면 같은 사람에게도 새 응답을 다시 보낸다 — 엔진 기본과 같은 결) */
const EXTERNAL_REPLY_HANDLE_PREFIX = 'ara-external-';
/** :handle 지문의 길이 (base64url 글자) */
const REPLY_HANDLE_HASH_LENGTH = 16;
/** 부재중 응답 하나 — 제목 · 보이는 글 · 서식(없으면 null) */
interface VacationReply {
readonly subject: string;
readonly textBody: string;
readonly htmlBody: string | null;
}
/**
@@ -108,23 +143,27 @@ type SieveExtension =
| 'body'
| 'copy'
| 'date'
| 'envelope'
| 'fileinto'
| 'imap4flags'
| 'mailbox'
| 'mailboxid'
| 'relational'
| 'vacation';
| 'vacation'
| 'variables';
const EXTENSION_ORDER: readonly SieveExtension[] = [
'body',
'copy',
'date',
'envelope',
'fileinto',
'imap4flags',
'mailbox',
'mailboxid',
'relational',
'vacation',
'variables',
];
/** 만든 스크립트 */
@@ -136,14 +175,20 @@ export interface BuiltFilterScript {
readonly longestStringBytes: number;
}
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
function sieveQuoted(value: string): string {
return `"${value.replace(/[\\"]/g, '\\$&')}"`;
}
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열 · 변수 이름을 모은다 */
class ScriptWriter {
readonly extensions = new Set<SieveExtension>();
longestStringBytes = 0;
private variables = 0;
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
quote(value: string): string {
const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`;
const quoted = sieveQuoted(value);
this.longestStringBytes = Math.max(
this.longestStringBytes,
Buffer.byteLength(quoted, 'utf8'),
@@ -154,6 +199,13 @@ class ScriptWriter {
use(...extensions: SieveExtension[]): void {
for (const extension of extensions) this.extensions.add(extension);
}
/** 조각을 담을 새 변수 이름 — 스크립트 안에서 겹치지 않게 차례로 (part0, part1, …) */
nextVariable(): string {
const name = `part${this.variables}`;
this.variables += 1;
return name;
}
}
function indent(depth: number, line: string): string {
@@ -219,20 +271,106 @@ function ruleBlock(
];
}
/**
* MIME 개체를 줄 끝(CRLF) 뒤에서 끊어 maxBytes 이하의 조각으로 — 이으면 원래 글이다. 한 줄이 maxBytes 보다 길면 그 줄만 글자 경계에서 자른다
* (우리 개체는 ASCII — 머리 · base64 줄은 76자라 그럴 일이 없다)
*/
function splitAtLineEnds(text: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = '';
let currentBytes = 0;
for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
const lineBytes = Buffer.byteLength(line, 'utf8');
if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) {
pieces.push(current);
current = '';
currentBytes = 0;
}
if (lineBytes > maxBytes) {
for (const character of line) {
const characterBytes = Buffer.byteLength(character, 'utf8');
if (currentBytes + characterBytes > maxBytes) {
pieces.push(current);
current = '';
currentBytes = 0;
}
current += character;
currentBytes += characterBytes;
}
continue;
}
current += line;
currentBytes += lineBytes;
}
if (current !== '') pieces.push(current);
return pieces;
}
/** 회사 밖 응답의 :handle — 응답 내용(제목 · MIME 개체)의 지문. 사내 응답과 늘 다르다 */
function externalReplyHandle(subject: string, entity: string): string {
const digest = createHash('sha256')
.update(`${subject}\n${entity}`, 'utf8')
.digest('base64url')
.slice(0, REPLY_HANDLE_HASH_LENGTH);
return `${EXTERNAL_REPLY_HANDLE_PREFIX}${digest}`;
}
/**
* 응답 하나 — `vacation :mime [:handle "…"] [:subject "…"] "<MIME 개체>";`. 서식이 있으면 글 + 서식 두 벌(multipart/alternative), 없으면 글만(지금과 같다).
* 개체가 문자열 하나 한도를 넘으면 앞에 조각 `set` 들을 두고 `"${part0}${part1}…"` 로 잇는다(variables).
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
*/
function vacationReplyLines(
writer: ScriptWriter,
reply: VacationReply,
side: 'internal' | 'external',
): string[] {
writer.use('vacation');
const subject = encodeHeaderWords(reply.subject);
const entity =
reply.htmlBody === null
? plainTextMimeEntity(reply.textBody)
: alternativeMimeEntity(reply.textBody, reply.htmlBody);
const lines: string[] = [];
let argument: string;
if (
Buffer.byteLength(sieveQuoted(entity), 'utf8') <= SIEVE_STRING_MAX_BYTES
) {
argument = writer.quote(entity);
} else {
writer.use('variables');
const names = splitAtLineEnds(entity, VACATION_CHUNK_BYTES).map((piece) => {
const name = writer.nextVariable();
lines.push(`set ${writer.quote(name)} ${writer.quote(piece)};`);
return name;
});
argument = writer.quote(names.map((name) => `\${${name}}`).join(''));
}
// 사내 응답은 :handle 을 쓰지 않는다(엔진 기본 — 15단계까지와 같은 글이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다)
const handle =
side === 'external'
? `:handle ${writer.quote(externalReplyHandle(subject, entity))} `
: '';
lines.push(
`vacation :mime ${handle}${subject ? `:subject ${writer.quote(subject)} ` : ''}${argument};`,
);
return lines;
}
/**
* 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다),
* 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word.
* `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
* `:days` 는 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
* 16단계 — 회사 밖 응답이 사내와 같지 않으면 봉투 보낸 사람의 도메인으로 가른다: 회사 도메인이면 사내 응답, 아니면 따로 쓴 응답(separate) · 없음(none).
* 회사 밖 응답은 `:handle` 을 따로 둔다(사내 응답과 같은 사람에게 다시 보내는 간격을 따로 센다)
*/
function vacationBlock(
writer: ScriptWriter,
vacation: MailVacation,
depth: number,
internalDomain: string | null,
): string[] {
writer.use('vacation');
const subject = encodeHeaderWords(vacation.subject);
const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`;
/** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */
const period = (relation: 'ge' | 'le', iso: string) =>
`currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`;
@@ -243,10 +381,54 @@ function vacationBlock(
];
if (periods.length > 0) writer.use('date', 'relational');
const inner = depth + periods.length;
const internalReply: VacationReply = {
subject: vacation.subject,
textBody: vacation.textBody,
htmlBody: vacation.htmlBody,
};
const { external } = vacation;
const replies: string[] = [];
if (external.mode === 'same') {
replies.push(
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
indent(inner, line),
),
);
} else {
if (!internalDomain) {
throw new Error('회사 밖 응답을 가르려면 회사 메일 도메인이 있어야 한다');
}
writer.use('envelope');
replies.push(
indent(
inner,
`if envelope :domain :is "from" ${writer.quote(internalDomain)} {`,
),
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
indent(inner + 1, line),
),
);
if (external.mode === 'separate') {
const externalReply: VacationReply = {
subject: external.subject ?? '',
textBody: external.textBody ?? '',
htmlBody: external.htmlBody,
};
replies.push(
indent(inner, '} else {'),
...vacationReplyLines(writer, externalReply, 'external').map((line) =>
indent(inner + 1, line),
),
);
}
replies.push(indent(inner, '}'));
}
const lines = periods.map((test, level) =>
indent(depth + level, `if ${test} {`),
);
lines.push(indent(depth + periods.length, command));
lines.push(...replies);
for (let level = periods.length - 1; level >= 0; level -= 1) {
lines.push(indent(depth + level, '}'));
}
@@ -295,7 +477,8 @@ function blockSection(
/**
* 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES).
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001)
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001).
* 켠 부재중의 회사 밖 응답이 사내와 같지 않은데 회사 메일 도메인을 받지 못해도 던진다 (어댑터가 늘 넘긴다)
*/
export function buildFilterScript(
source: FilterScriptSource,
@@ -310,7 +493,14 @@ export function buildFilterScript(
}
const body: string[] = [];
if (source.vacation.isEnabled) {
body.push(...vacationBlock(writer, source.vacation, 1));
body.push(
...vacationBlock(
writer,
source.vacation,
1,
options.internalDomain ?? null,
),
);
}
for (const rule of source.rules) {
if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1));
@@ -1,3 +1,5 @@
import type { MailVacationExternalMode } from './mail.types';
/**
* 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다.
*/
@@ -83,6 +85,20 @@ export const MAX_VACATION_TEXT_LENGTH = 600;
export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200;
export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000;
/**
* 부재중 응답의 서식 본문(16단계) 최대 길이 — 보내는 정화(그림 없음)를 거친 뒤의 JS 문자열 길이. 서명과 같은 한도(SIGNATURE_HTML_MAX_LENGTH).
* 켤 때만 본다(회사 밖 서식 본문은 따로 쓰기일 때만) — 넘으면 400 VAL_001. 정화 전 길이 · 파서 비용은 늘 본다(html/sanitize-stored-html.ts).
* 엔진에는 base64 로 들어가고 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 variables 조각으로 나눈다 (jmap/sieve-script.ts)
*/
export const MAX_VACATION_HTML_LENGTH = 10_000;
/** 회사 밖 응답의 고르기 (16단계 — 계약 2절 "부재중 자동 응답") */
export const MAIL_VACATION_EXTERNAL_MODES = [
'same',
'none',
'separate',
] as const satisfies readonly MailVacationExternalMode[];
/**
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
*/
+201 -16
View File
@@ -874,19 +874,37 @@ describe('MailService', () => {
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
/** 16단계 칸이 없는 옛 요청이 저장되는 값 — 글만 · 사내와 같은 응답 */
const TEXT_ONLY = {
htmlBody: null,
external: {
mode: 'same' as const,
subject: null,
textBody: null,
htmlBody: null,
},
};
const echo = () =>
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답, 로그에는 켬 · 끔 · 서식 · 회사 밖 고르기만', async () => {
echo();
await expect(service.updateVacation(user, vacation)).resolves.toEqual({
...vacation,
...TEXT_ONLY,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
...vacation,
...TEXT_ONLY,
});
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('켬');
expect(line).toContain('서식 없음');
expect(line).toContain('회사 밖 same');
expect(line).not.toContain(vacation.subject);
expect(line).not.toContain(vacation.textBody);
expect(line).not.toContain(user.email);
@@ -911,6 +929,7 @@ describe('MailService', () => {
toDate: null,
subject: '',
textBody: '',
...TEXT_ONLY,
};
provider.setVacation.mockResolvedValue({
...neverConfigured,
@@ -934,9 +953,7 @@ describe('MailService', () => {
}
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, {
...vacation,
isEnabled: false,
@@ -950,9 +967,10 @@ describe('MailService', () => {
expect(
provider.setVacation.mock.calls.map(([, value]) => value),
).toEqual([
{ ...vacation, isEnabled: false, subject: ' ' },
{ ...vacation, ...TEXT_ONLY, isEnabled: false, subject: ' ' },
{
...vacation,
...TEXT_ONLY,
subject: ' 부재중입니다 ',
textBody: '\n휴가입니다.\n',
},
@@ -990,9 +1008,7 @@ describe('MailService', () => {
textLength: vacation.textBody.length,
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
// 한도까지는 켜도 받는다
const full = {
...vacation,
@@ -1001,6 +1017,7 @@ describe('MailService', () => {
};
await expect(service.updateVacation(user, full)).resolves.toEqual({
...full,
...TEXT_ONLY,
isActive: true,
});
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
@@ -1012,6 +1029,7 @@ describe('MailService', () => {
};
await expect(service.updateVacation(user, off)).resolves.toEqual({
...off,
...TEXT_ONLY,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledTimes(2);
@@ -1029,9 +1047,7 @@ describe('MailService', () => {
).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' });
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, {
...vacation,
fromDate: '2026-11-02T15:00:00.999Z',
@@ -1039,6 +1055,7 @@ describe('MailService', () => {
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
...vacation,
...TEXT_ONLY,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-02T15:00:01.000Z',
});
@@ -1054,13 +1071,174 @@ describe('MailService', () => {
}
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, { ...vacation, toDate: null });
await service.updateVacation(user, { ...vacation, fromDate: null });
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
describe('서식 본문 · 회사 밖 응답 (16단계)', () => {
const external = {
mode: 'separate' as const,
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am <strong>away</strong> until Nov 7.</p>',
};
it('서식 본문은 보내는 정화(그림 없음)를 거쳐 넘긴다 — 정화해서 비면 null(글만), 회사 밖 칸도 같다. 로그에는 서식 있음 · 고르기만', async () => {
echo();
const saved = await service.updateVacation(user, {
...vacation,
htmlBody:
'<p onclick="x()">11월 7일까지 <strong>휴가</strong>입니다.<script>x()</script></p><img src="https://evil.example/x.png"><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d">',
external: {
...external,
htmlBody: '<script>x()</script>',
},
});
expect(saved).toEqual({
...vacation,
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: { ...external, htmlBody: null },
isActive: true,
});
// 서식에 그림은 없다 — 서명 그림 주소도 정화가 지운다(그 사람의 그림 목록을 읽지 않는다)
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('서식 있음');
expect(line).toContain('회사 밖 separate');
expect(line).not.toContain('Out of office');
expect(line).not.toContain('휴가');
});
it('회사 밖 글 칸의 빈 글은 null 로 — 다른 고르기여도 써 둔 글은 그대로 넘긴다, external 이 null 이면 사내와 같은 응답', async () => {
echo();
await service.updateVacation(user, {
...vacation,
external: { ...external, mode: 'none' },
});
await service.updateVacation(user, {
...vacation,
external: {
mode: 'same',
subject: '',
textBody: 'x',
htmlBody: '',
},
});
await service.updateVacation(user, { ...vacation, external: null });
expect(
provider.setVacation.mock.calls.map(([, value]) => value.external),
).toEqual([
{ ...external, mode: 'none' },
{ mode: 'same', subject: null, textBody: 'x', htmlBody: null },
TEXT_ONLY.external,
]);
});
it('따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 본다 — 비면 · 150 · 600자를 넘으면 VAL_001, 끈 채로 · 다른 고르기면 비어도 저장한다', async () => {
for (const [changed, reason] of [
[{ subject: null }, 'vacation-external-without-text'],
[{ textBody: ' ' }, 'vacation-external-without-text'],
[
{ subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
'vacation-external-text-too-long',
],
[
{ textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
'vacation-external-text-too-long',
],
] as const) {
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
external: { ...external, ...changed },
}),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(provider.setVacation).not.toHaveBeenCalled();
echo();
const empty = { subject: null, textBody: null, htmlBody: null };
await service.updateVacation(user, {
...vacation,
isEnabled: false,
external: { mode: 'separate', ...empty },
});
for (const mode of ['same', 'none'] as const) {
await service.updateVacation(user, {
...vacation,
external: { mode, ...empty },
});
}
expect(provider.setVacation).toHaveBeenCalledTimes(3);
});
it('켤 때 정화한 서식은 10,000자까지 — 넘으면 VAL_001(회사 밖은 따로 쓰기일 때만), 정화 전 비용은 늘 본다', async () => {
const long = `<p>${'가'.repeat(10_000)}</p>`;
expect(
await failureOf(() =>
service.updateVacation(user, { ...vacation, htmlBody: long }),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
external: { ...external, htmlBody: long },
}),
),
).toEqual({
code: 'VAL_001',
reason: 'vacation-external-html-too-long',
});
for (const isEnabled of [true, false]) {
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
isEnabled,
htmlBody: '<b>'.repeat(166_666),
}),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-complex' });
}
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
isEnabled: false,
htmlBody: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1),
}),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
expect(provider.setVacation).not.toHaveBeenCalled();
echo();
// 끈 채로는 · 다른 고르기의 회사 밖 서식은 10,000자를 넘어도 저장한다(켤 때 본다)
await service.updateVacation(user, {
...vacation,
isEnabled: false,
htmlBody: long,
});
await service.updateVacation(user, {
...vacation,
external: { ...external, mode: 'none', htmlBody: long },
});
// 경계 — 정화한 결과가 정확히 10,000자면 켜도 받는다
await service.updateVacation(user, {
...vacation,
htmlBody: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
expect(provider.setVacation).toHaveBeenCalledTimes(3);
});
});
});
it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => {
@@ -1814,6 +1992,13 @@ describe('MailService', () => {
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
htmlBody: '<p>휴가입니다.</p>',
external: {
mode: 'none' as const,
subject: null,
textBody: null,
htmlBody: null,
},
isActive: false,
};
provider.getVacation.mockResolvedValue(state);
+70 -2
View File
@@ -64,6 +64,7 @@ import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import { sanitizeStoredHtml } from './html/sanitize-stored-html';
import {
SIGNATURE_IMAGE_PATH_PREFIX,
findSignatureImageIds,
@@ -78,6 +79,7 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
SCHEDULED_SEND_MAX_LEAD_MS,
@@ -248,6 +250,34 @@ function assertHtmlBytes(html: string): void {
}
}
/**
* 부재중 응답의 서식 본문(16단계) — 보내는 정화(그림 없음)를 거친 값, 없거나 비면 null. 정화 전 길이 · 파서 비용은 늘 보고,
* 정화 뒤 MAX_VACATION_HTML_LENGTH 는 `enforceLength` 일 때만(켤 때 — 회사 밖은 따로 쓰기일 때) 본다. 넘으면 VAL_001 (사유 · 길이만 — 글은 남기지 않는다)
*/
function vacationHtml(
raw: string | null | undefined,
enforceLength: boolean,
reason: 'vacation-html' | 'vacation-external-html',
): string | null {
const sanitized = sanitizeStoredHtml(raw, {
maxLength: enforceLength ? MAX_VACATION_HTML_LENGTH : null,
});
if (!sanitized.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: `${reason}-${sanitized.problem}`,
length: sanitized.length,
},
});
}
return sanitized.html;
}
/** 회사 밖 응답의 글 칸 — 빈 글은 "쓴 적 없음"(null) */
function writtenOrNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
/** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
const { isRead, isFlagged, mailboxId, isJunk } = dto;
@@ -549,18 +579,35 @@ export class MailService {
* - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 —
* 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거")
* - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
* - 서식 본문(16단계)은 보내는 정화(그림 없음)를 거쳐 저장한다 — 켤 때는 정화 뒤 10,000자까지(정화 전 길이 · 파서 비용은 늘 본다)
* - 회사 밖 응답(16단계) — 따로 쓰기이고 켤 때만 그 제목 · 내용을 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 본다.
* 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장하고, 따로 써 둔 글은 고르기와 상관없이 그대로 저장한다(빈 글은 null)
* - 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답으로 저장한다(옛 화면이 보는 그대로)
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 (제목 · 내용 없이)
*/
async updateVacation(
user: AuthenticatedUser,
dto: MailVacationDto,
): Promise<MailVacationState> {
const external = dto.external ?? null;
const mode = external?.mode ?? 'same';
const vacation: MailVacation = {
isEnabled: dto.isEnabled,
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate),
subject: dto.subject,
textBody: dto.textBody,
htmlBody: vacationHtml(dto.htmlBody, dto.isEnabled, 'vacation-html'),
external: {
mode,
subject: writtenOrNull(external?.subject),
textBody: writtenOrNull(external?.textBody),
htmlBody: vacationHtml(
external?.htmlBody,
dto.isEnabled && mode === 'separate',
'vacation-external-html',
),
},
};
if (
vacation.isEnabled &&
@@ -584,6 +631,27 @@ export class MailService {
},
});
}
if (vacation.isEnabled && mode === 'separate') {
const subject = vacation.external.subject ?? '';
const textBody = vacation.external.textBody ?? '';
if (subject.trim() === '' || textBody.trim() === '') {
throw new BusinessException('VAL_001', {
context: { reason: 'vacation-external-without-text' },
});
}
if (
subject.length > MAX_VACATION_SUBJECT_LENGTH ||
textBody.length > MAX_VACATION_TEXT_LENGTH
) {
throw new BusinessException('VAL_001', {
context: {
reason: 'vacation-external-text-too-long',
subjectLength: subject.length,
textLength: textBody.length,
},
});
}
}
if (
vacation.fromDate !== null &&
vacation.toDate !== null &&
@@ -596,7 +664,7 @@ export class MailService {
const saved = await this.provider.setVacation(user.email, vacation);
this.logger.log(
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`,
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}, 서식 ${saved.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${saved.external.mode}`,
);
return saved;
}
+22 -1
View File
@@ -327,6 +327,23 @@ export interface MailboxBulkResult {
readonly remaining: number;
}
/**
* 회사 밖에서 온 메일(봉투 보낸 사람의 도메인이 `MAIL_DOMAIN` 이 아님)에 하는 응답 (16단계) —
* same: 사내와 같은 응답 · none: 보내지 않기 · separate: 따로 쓴 제목 · 내용으로
*/
export type MailVacationExternalMode = 'same' | 'none' | 'separate';
/** 회사 밖 응답 (16단계) — 다른 mode 로 바꿔도 따로 쓴 글은 남겨 둔다(함께 저장한다) */
export interface MailVacationExternal {
readonly mode: MailVacationExternalMode;
/** 따로 쓰기의 제목 — 쓴 적 없으면(빈 글) null */
readonly subject: string | null;
/** 따로 쓰기의 글 — 쓴 적 없으면(빈 글) null */
readonly textBody: string | null;
/** 따로 쓰기의 서식 본문(정화한 값 — 그림 없음) — 없으면 null(글만) */
readonly htmlBody: string | null;
}
/**
* 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 저장은 메일 필터와 같은 엔진 규칙에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리)
*/
@@ -338,8 +355,12 @@ export interface MailVacation {
readonly toDate: string | null;
/** 엔진에 없으면(null) 빈 문자열 */
readonly subject: string;
/** 글자만(서식 없음) — 엔진에 없으면(null) 빈 문자열 */
/** 보이는 글(서식을 뺀 글) — 엔진에 없으면(null) 빈 문자열. 서식 본문이 있으면 받는 쪽에는 두 벌(글 + 서식)로 간다 */
readonly textBody: string;
/** 서식 본문(정화한 값 — 그림 없음, 16단계) — null 이면 글만 */
readonly htmlBody: string | null;
/** 회사 밖에서 온 메일에 하는 응답 (16단계) — 처음 값은 사내와 같은 응답 */
readonly external: MailVacationExternal;
}
/** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */
@@ -42,6 +42,7 @@ import {
import { MailIdPipe } from './mail-id.pipe';
import {
MAX_MAILBOX_NAME_LENGTH,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
@@ -234,7 +235,8 @@ export class MailVacationController {
@ApiOperation({
summary: '부재중 자동 응답',
description:
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' +
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, ' +
'external: { mode: "same", subject: null, textBody: null, htmlBody: null }, isActive: true } 다(서식 본문 · 회사 밖 응답은 16단계). ' +
'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).',
})
@ApiStandardResponse(MailVacationStateDto, {
@@ -253,7 +255,10 @@ export class MailVacationController {
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
`16단계 — htmlBody(서식 본문, 그림 없음 — 정화해 저장, 켤 때 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지)와 external(회사 밖에서 온 메일 — same 사내와 같은 응답 · none 보내지 않기 · separate 따로 쓰기)을 받는다. ` +
'둘 다 보내지 않아도 된다(글만 · 사내와 같은 응답으로 저장). 따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 사내와 같은 규칙으로 본다 — 다른 고르기여도 써 둔 글은 그대로 저장한다. ' +
'사내 · 회사 밖은 봉투 보낸 사람의 도메인(회사 메일 도메인인지)으로 가른다. ' +
'저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).',
})
@ApiStandardResponse(MailVacationStateDto, {
@@ -261,7 +266,7 @@ export class MailVacationController {
})
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자 · 정화한 서식 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자 — 따로 쓰기면 회사 밖 칸도) · 서식이 너무 깊게 중첩됨 · 제어 문자 · 켤 때 공백뿐인 제목 · 내용(따로 쓰기면 회사 밖 칸도) · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
'/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
File diff suppressed because it is too large Load Diff